<html lang="en">
<head></head>
<body>

<form id="mainForm" method="post" action="https://stackblitz.com/run" target="_self">
<input type="hidden" name="project[files][README.md]" value="# Sync Contacts for RivetKit

Example project demonstrating offline-first contact synchronization with conflict resolution using [RivetKit](https://rivetkit.org).

[Learn More →](https://github.com/rivet-dev/rivetkit)

[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues)

## Getting Started

### Prerequisites

- Node.js 18+

### Installation

```sh
git clone https://github.com/rivet-dev/rivetkit
cd rivetkit/examples/sync
npm install
```

### Development

```sh
npm run dev
```

Open your browser to `http://localhost:3000`

## Features

- **Offline-First Sync**: Add contacts locally, sync when connection available
- **Conflict Resolution**: &quot;Last write wins&quot; conflict resolution using timestamps
- **Real-time Updates**: See changes from other clients immediately
- **Soft Deletes**: Deleted contacts are marked as deleted, not removed
- **Periodic Sync**: Automatic background synchronization every 5 seconds
- **Manual Sync**: Force synchronization with &quot;Sync Now&quot; button
- **Sync Statistics**: Track total contacts, deletions, and last sync time
- **Connection Status**: Visual indicators for sync status (Synced/Syncing/Offline)

## How it works

This contact sync system demonstrates:

1. **Offline-First Architecture**: Changes are applied locally first for immediate UI feedback
2. **Conflict Resolution**: Server uses timestamp-based &quot;last write wins&quot; strategy
3. **Event Broadcasting**: Real-time updates sent to all connected clients
4. **Soft Delete Pattern**: Deleted contacts marked with empty name instead of removal
5. **Periodic Synchronization**: Background sync every 5 seconds to catch remote changes
6. **Optimistic Updates**: UI updates immediately before server confirmation

## Architecture

- **Backend**: RivetKit actor managing contact state and synchronization logic
- **Frontend**: React application with offline-first contact management
- **Sync Strategy**: Timestamp-based conflict resolution with periodic reconciliation
- **State Management**: Server-side persistence with client-side optimistic updates

## Synchronization Flow

### Adding Contacts
```typescript
// 1. Add locally for immediate UI feedback
setContacts(prev =&gt; [...prev, newContact]);

// 2. Push to server for persistence and broadcast
await actor.pushChanges([newContact]);
```

### Conflict Resolution
```typescript
// Server-side: Last write wins based on timestamp
if (!existing || existing.updatedAt &lt; contact.updatedAt) {
    state.contacts[contact.id] = contact;
}
```

### Periodic Sync
```typescript
// Every 5 seconds:
// 1. Get remote changes since last sync
const changes = await actor.getChanges(lastSyncTime);

// 2. Apply remote changes locally
// 3. Push any local changes to server
// 4. Update last sync timestamp
```

## Sync Strategies

This example implements **Last Write Wins** conflict resolution, but the pattern supports other strategies:

### Last Write Wins (Current Implementation)
- Simple timestamp comparison
- Most recent change takes precedence
- Easy to implement and understand
- Risk of data loss in concurrent edits

### Alternative Strategies
- **Operational Transform**: Transform operations to maintain intent
- **CRDTs**: Conflict-free replicated data types for automatic resolution
- **Three-Way Merge**: Compare base, local, and remote versions
- **User-Prompted Resolution**: Ask user to resolve conflicts manually

## Use Cases

This sync pattern is perfect for:

- **Contact Management**: Personal and business contact lists
- **Note Taking**: Distributed note-taking applications
- **Todo Lists**: Task management with offline support
- **Settings Sync**: User preferences across devices
- **Shopping Lists**: Collaborative shopping with family/friends
- **Inventory Management**: Small business inventory tracking

## Extending

This sync system can be enhanced with:

- **User Authentication**: Per-user contact isolation
- **Categories/Tags**: Organize contacts into groups
- **Import/Export**: Bulk contact operations
- **Search/Filtering**: Find contacts quickly
- **Merge Conflicts**: UI for manual conflict resolution
- **Backup/Restore**: Data protection features
- **Sharing**: Share contacts between users
- **Versioning**: Track contact change history
- **Advanced Sync**: Delta sync for large datasets

## Offline Behavior

### When Offline
- Contacts can still be added/deleted locally
- Changes are queued for next sync
- UI shows &quot;Offline&quot; status
- All functionality remains available

### When Reconnecting
- Automatic sync of queued changes
- Conflict resolution applied
- Status updates to &quot;Syncing&quot; then &quot;Synced&quot;
- Real-time updates resume

## Testing Offline Sync

To test offline functionality:

1. **Add contacts** while online
2. **Disconnect network** (disable WiFi or ethernet)
3. **Add more contacts** - they appear locally
4. **Reconnect network** - contacts sync automatically
5. **Open multiple tabs** - see real-time sync between clients

## Performance Considerations

### Optimization Strategies
- **Delta Sync**: Only sync changes since last sync
- **Batching**: Group multiple changes into single requests
- **Compression**: Compress sync payloads for large datasets
- **Indexing**: Index by timestamp for efficient change queries
- **Pagination**: Handle large contact lists efficiently

### Scalability Notes
- Current implementation stores all contacts in memory
- For production, consider database persistence
- Implement pagination for large contact lists
- Add rate limiting for sync operations
- Consider WebSocket connections for real-time updates

## Error Handling

The system handles various error scenarios:

- **Network Failures**: Fall back to offline mode
- **Server Errors**: Retry with exponential backoff
- **Sync Conflicts**: Automatic resolution with timestamps
- **Invalid Data**: Validation before persistence
- **Connection Loss**: Queue changes for later sync

## License

Apache 2.0">
<input type="hidden" name="project[files][package.json]" value="{&quot;name&quot;:&quot;example-sync&quot;,&quot;version&quot;:&quot;2.0.21&quot;,&quot;type&quot;:&quot;module&quot;,&quot;scripts&quot;:{&quot;dev&quot;:&quot;concurrently \&quot;tsx --watch src/backend/server.ts\&quot; \&quot;vite\&quot;&quot;,&quot;build&quot;:&quot;vite build&quot;,&quot;preview&quot;:&quot;vite preview&quot;,&quot;check-types&quot;:&quot;tsc --noEmit&quot;,&quot;test&quot;:&quot;vitest&quot;},&quot;dependencies&quot;:{&quot;rivetkit&quot;:&quot;https://pkg.pr.new/rivet-dev/rivet/rivetkit@af583ac8b43832d9ab014542396c3c30dbf63767&quot;,&quot;@rivetkit/react&quot;:&quot;https://pkg.pr.new/rivet-dev/rivet/@rivetkit/react@af583ac8b43832d9ab014542396c3c30dbf63767&quot;,&quot;react&quot;:&quot;^18.2.0&quot;,&quot;react-dom&quot;:&quot;^18.2.0&quot;},&quot;devDependencies&quot;:{&quot;@types/node&quot;:&quot;^20.0.0&quot;,&quot;@types/react&quot;:&quot;^18.2.0&quot;,&quot;@types/react-dom&quot;:&quot;^18.2.0&quot;,&quot;@vitejs/plugin-react&quot;:&quot;^4.0.0&quot;,&quot;concurrently&quot;:&quot;^8.2.0&quot;,&quot;tsx&quot;:&quot;^4.0.0&quot;,&quot;typescript&quot;:&quot;^5.0.0&quot;,&quot;vite&quot;:&quot;^5.0.0&quot;,&quot;vitest&quot;:&quot;^3.1.1&quot;}}">
<input type="hidden" name="project[files][tsconfig.json]" value="{
	&quot;compilerOptions&quot;: {
		&quot;target&quot;: &quot;ES2020&quot;,
		&quot;lib&quot;: [&quot;ES2020&quot;, &quot;DOM&quot;, &quot;DOM.Iterable&quot;],
		&quot;module&quot;: &quot;ESNext&quot;,
		&quot;skipLibCheck&quot;: true,
		&quot;moduleResolution&quot;: &quot;bundler&quot;,
		&quot;allowImportingTsExtensions&quot;: true,
		&quot;resolveJsonModule&quot;: true,
		&quot;isolatedModules&quot;: true,
		&quot;noEmit&quot;: true,
		&quot;jsx&quot;: &quot;react-jsx&quot;,
		&quot;strict&quot;: true,
		&quot;noUnusedLocals&quot;: true,
		&quot;noUnusedParameters&quot;: true,
		&quot;noFallthroughCasesInSwitch&quot;: true
	},
	&quot;include&quot;: [&quot;src&quot;, &quot;tests&quot;],
	&quot;exclude&quot;: [&quot;node_modules&quot;, &quot;dist&quot;]
}
">
<input type="hidden" name="project[files][turbo.json]" value="{
	&quot;$schema&quot;: &quot;https://turbo.build/schema.json&quot;,
	&quot;extends&quot;: [&quot;//&quot;]
}
">
<input type="hidden" name="project[files][vite.config.ts]" value="import react from &quot;@vitejs/plugin-react&quot;;
import { defineConfig } from &quot;vite&quot;;

export default defineConfig({
	plugins: [react()],
	root: &quot;src/frontend&quot;,
	server: {
		port: 3000,
	},
	build: {
		outDir: &quot;../../dist&quot;,
		emptyOutDir: true,
	},
});
">
<input type="hidden" name="project[files][vitest.config.ts]" value="import { defineConfig } from &quot;vitest/config&quot;;

export default defineConfig({
	test: {
		environment: &quot;node&quot;,
	},
});
">
<input type="hidden" name="project[files][tests/sync.test.ts]" value="import { setupTest } from &quot;rivetkit/test&quot;;
import { expect, test } from &quot;vitest&quot;;
import { registry } from &quot;../src/backend/registry&quot;;

test(&quot;Sync system can handle contact synchronization&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const sync = client.contacts.getOrCreate([&quot;test-sync-new&quot;]);

	// Initial state should be empty (or may have existing data)
	const initialContacts = await sync.getAllContacts();
	const initialCount = initialContacts.length;

	// Push some contacts
	const contacts = [
		{
			id: &quot;1&quot;,
			name: &quot;Alice Johnson&quot;,
			email: &quot;alice@example.com&quot;,
			phone: &quot;555-0001&quot;,
			updatedAt: Date.now() - 1000,
		},
		{
			id: &quot;2&quot;,
			name: &quot;Bob Smith&quot;,
			email: &quot;bob@example.com&quot;,
			phone: &quot;555-0002&quot;,
			updatedAt: Date.now(),
		},
	];

	const pushResult = await sync.pushChanges(contacts);
	expect(pushResult).toMatchObject({
		timestamp: expect.any(Number),
	});

	// Verify contacts were stored
	const allContacts = await sync.getAllContacts();
	expect(allContacts).toHaveLength(initialCount + 2);
	expect(allContacts).toEqual(expect.arrayContaining(contacts));
});

test(&quot;Sync system handles conflict resolution with last-write-wins&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const sync = client.contacts.getOrCreate([&quot;test-conflicts&quot;]);

	const oldTimestamp = Date.now() - 2000;
	const newTimestamp = Date.now();

	// Push initial contact
	const originalContact = {
		id: &quot;conflict-test&quot;,
		name: &quot;Original Name&quot;,
		email: &quot;original@example.com&quot;,
		phone: &quot;555-0000&quot;,
		updatedAt: oldTimestamp,
	};

	await sync.pushChanges([originalContact]);

	// Push conflicting update with newer timestamp
	const updatedContact = {
		id: &quot;conflict-test&quot;,
		name: &quot;Updated Name&quot;,
		email: &quot;updated@example.com&quot;,
		phone: &quot;555-1111&quot;,
		updatedAt: newTimestamp,
	};

	await sync.pushChanges([updatedContact]);

	// Verify newer version won
	const contacts = await sync.getAllContacts();
	const conflictContact = contacts.find((c) =&gt; c.id === &quot;conflict-test&quot;);
	expect(conflictContact).toEqual(updatedContact);

	// Try to push older version - should be ignored
	const olderContact = {
		id: &quot;conflict-test&quot;,
		name: &quot;Older Name&quot;,
		email: &quot;older@example.com&quot;,
		phone: &quot;555-9999&quot;,
		updatedAt: oldTimestamp - 1000,
	};

	await sync.pushChanges([olderContact]);

	// Verify newer version is still there
	const finalContacts = await sync.getAllContacts();
	const finalContact = finalContacts.find((c) =&gt; c.id === &quot;conflict-test&quot;);
	expect(finalContact).toEqual(updatedContact);
});

test(&quot;Sync system tracks changes after timestamp&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const sync = client.contacts.getOrCreate([&quot;test-changes&quot;]);

	const baseTime = Date.now();

	// Add some contacts at different times
	const contact1 = {
		id: &quot;1&quot;,
		name: &quot;First Contact&quot;,
		email: &quot;first@example.com&quot;,
		phone: &quot;555-0001&quot;,
		updatedAt: baseTime - 1000,
	};

	const contact2 = {
		id: &quot;2&quot;,
		name: &quot;Second Contact&quot;,
		email: &quot;second@example.com&quot;,
		phone: &quot;555-0002&quot;,
		updatedAt: baseTime + 1000,
	};

	await sync.pushChanges([contact1]);
	await sync.pushChanges([contact2]);

	// Get changes after base time - should only return contact2
	const changes = await sync.getChanges(baseTime);
	expect(changes.changes).toHaveLength(1);
	expect(changes.changes[0]).toEqual(contact2);
	expect(changes.timestamp).toBeGreaterThanOrEqual(baseTime);

	// Get all changes - should return both
	const allChanges = await sync.getChanges(0);
	expect(allChanges.changes).toHaveLength(2);
});

test(&quot;Sync system provides statistics&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const sync = client.contacts.getOrCreate([&quot;test-stats-new&quot;]);

	// Initial stats
	const initialStats = await sync.getSyncStats();
	expect(initialStats).toMatchObject({
		totalContacts: expect.any(Number),
		lastSyncTime: expect.any(Number),
		deletedContacts: expect.any(Number),
	});

	const initialTotal = initialStats.totalContacts;

	// Add some contacts
	const contacts = [
		{
			id: &quot;1&quot;,
			name: &quot;Contact 1&quot;,
			email: &quot;c1@example.com&quot;,
			phone: &quot;555-0001&quot;,
			updatedAt: Date.now(),
		},
		{
			id: &quot;2&quot;,
			name: &quot;Contact 2&quot;,
			email: &quot;c2@example.com&quot;,
			phone: &quot;555-0002&quot;,
			updatedAt: Date.now(),
		},
		{
			id: &quot;3&quot;,
			name: &quot;&quot;,
			email: &quot;deleted@example.com&quot;,
			phone: &quot;555-0003&quot;,
			updatedAt: Date.now(),
		}, // Deleted contact
	];

	await sync.pushChanges(contacts);

	const stats = await sync.getSyncStats();
	expect(stats.totalContacts).toBe(initialTotal + 2); // Only non-deleted contacts
	expect(stats.deletedContacts).toBeGreaterThanOrEqual(1);
	expect(stats.lastSyncTime).toBeGreaterThan(initialStats.lastSyncTime);
});

test(&quot;Sync system reset functionality&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const sync = client.contacts.getOrCreate([&quot;test-reset&quot;]);

	// Add some contacts
	const contacts = [
		{
			id: &quot;1&quot;,
			name: &quot;Contact 1&quot;,
			email: &quot;c1@example.com&quot;,
			phone: &quot;555-0001&quot;,
			updatedAt: Date.now(),
		},
		{
			id: &quot;2&quot;,
			name: &quot;Contact 2&quot;,
			email: &quot;c2@example.com&quot;,
			phone: &quot;555-0002&quot;,
			updatedAt: Date.now(),
		},
	];

	await sync.pushChanges(contacts);

	// Verify contacts exist
	let allContacts = await sync.getAllContacts();
	expect(allContacts).toHaveLength(2);

	// Reset the system
	const resetResult = await sync.reset();
	expect(resetResult).toMatchObject({
		timestamp: expect.any(Number),
	});

	// Verify contacts are gone
	allContacts = await sync.getAllContacts();
	expect(allContacts).toEqual([]);

	const stats = await sync.getSyncStats();
	expect(stats.totalContacts).toBe(0);
});
">
<input type="hidden" name="project[files][src/backend/registry.ts]" value="import { actor, setup } from &quot;rivetkit&quot;;
import type { Contact } from &quot;./types&quot;;

const contacts = actor({
	// State is automatically persisted
	// Persistent state that survives restarts
	state: {
		contacts: {} as Record&lt;string, Contact&gt;,
		lastSyncTime: Date.now(),
	},

	actions: {
		// Callable functions from clients
		getChanges: (c, after = 0) =&gt; {
			const changes = Object.values(c.state.contacts).filter(
				(contact) =&gt; contact.updatedAt &gt; after,
			);

			return {
				changes,
				timestamp: Date.now(),
			};
		},

		pushChanges: (c, contactList: Contact[]) =&gt; {
			let changed = false;

			contactList.forEach((contact) =&gt; {
				const existing = c.state.contacts[contact.id];

				// Last write wins conflict resolution based on timestamp
				if (!existing || existing.updatedAt &lt; contact.updatedAt) {
					// State changes are automatically persisted
					c.state.contacts[contact.id] = contact;
					changed = true;
				}
			});

			// Update last sync time
			c.state.lastSyncTime = Date.now();

			if (changed) {
				// Send events to all connected clients
				c.broadcast(&quot;contactsChanged&quot;, {
					contacts: Object.values(c.state.contacts).filter(
						(c) =&gt; c.name !== &quot;&quot;,
					),
				});
			}

			return { timestamp: c.state.lastSyncTime };
		},

		getAllContacts: (c) =&gt; {
			return Object.values(c.state.contacts).filter(
				(contact) =&gt; contact.name !== &quot;&quot;,
			);
		},

		getSyncStats: (c) =&gt; {
			const allContacts = Object.values(c.state.contacts);
			const activeContacts = allContacts.filter(
				(contact) =&gt; contact.name !== &quot;&quot;,
			);

			return {
				totalContacts: activeContacts.length,
				lastSyncTime: c.state.lastSyncTime,
				deletedContacts: allContacts.filter(
					(contact) =&gt; contact.name === &quot;&quot;,
				).length,
			};
		},

		reset: (c) =&gt; {
			c.state.contacts = {};
			c.state.lastSyncTime = Date.now();

			c.broadcast(&quot;contactsChanged&quot;, {
				contacts: [],
			});

			return { timestamp: c.state.lastSyncTime };
		},
	},
});

// Register actors for use
export const registry = setup({
	use: { contacts },
});
">
<input type="hidden" name="project[files][src/backend/server.ts]" value="import { registry } from &quot;./registry&quot;;

registry.start();
">
<input type="hidden" name="project[files][src/backend/types.ts]" value="export type Contact = {
	id: string;
	name: string;
	email: string;
	phone: string;
	updatedAt: number;
};
">
<input type="hidden" name="project[files][src/frontend/App.tsx]" value="import { createRivetKit } from &quot;@rivetkit/react&quot;;
import { useEffect, useRef, useState } from &quot;react&quot;;
import type { Contact } from &quot;../backend/types&quot;;
import type { registry } from &quot;../backend/registry&quot;;

const { useActor } = createRivetKit&lt;typeof registry&gt;(&quot;http://localhost:8080&quot;);

export function App() {
	const [contacts, setContacts] = useState&lt;Contact[]&gt;([]);
	const [name, setName] = useState(&quot;&quot;);
	const [email, setEmail] = useState(&quot;&quot;);
	const [phone, setPhone] = useState(&quot;&quot;);
	const [syncStatus, setSyncStatus] = useState&lt;&quot;Idle&quot; | &quot;Syncing&quot; | &quot;Synced&quot; | &quot;Offline&quot;&gt;(&quot;Idle&quot;);
	const [stats, setStats] = useState({ totalContacts: 0, lastSyncTime: 0, deletedContacts: 0 });

	const lastSyncTime = useRef(0);
	const syncIntervalRef = useRef&lt;NodeJS.Timeout | null&gt;(null);

	const contactsActor = useActor({
		name: &quot;contacts&quot;,
		key: [&quot;global&quot;],
	});

	// Load initial contacts and stats
	useEffect(() =&gt; {
		if (!contactsActor.connection) return;

		const loadInitialData = async () =&gt; {
			try {
				const data = await contactsActor.connection!.getChanges(0);
				setContacts(data.changes);
				lastSyncTime.current = data.timestamp;
				setSyncStatus(&quot;Synced&quot;);

				const statsData = await contactsActor.connection!.getSyncStats();
				setStats(statsData);
			} catch (error) {
				setSyncStatus(&quot;Offline&quot;);
			}
		};

		loadInitialData();
	}, [contactsActor.connection]);

	// Handle contact events from other clients
	contactsActor.useEvent(&quot;contactsChanged&quot;, ({ contacts: updatedContacts }: { contacts: Contact[] }) =&gt; {
		setContacts((prev) =&gt; {
			const contactMap = new Map(prev.map((c) =&gt; [c.id, c]));

			updatedContacts.forEach((contact) =&gt; {
				const existing = contactMap.get(contact.id);
				if (!existing || existing.updatedAt &lt; contact.updatedAt) {
					contactMap.set(contact.id, contact);
				}
			});

			return Array.from(contactMap.values()).filter(c =&gt; c.name !== &quot;&quot;);
		});

		// Update stats when contacts change
		if (contactsActor.connection) {
			contactsActor.connection.getSyncStats().then(setStats);
		}
	});

	// Periodic sync - every 5 seconds
	useEffect(() =&gt; {
		if (!contactsActor.connection) return;

		const sync = async () =&gt; {
			setSyncStatus(&quot;Syncing&quot;);

			try {
				// Get remote changes
				const changes = await contactsActor.connection!.getChanges(lastSyncTime.current);

				// Apply remote changes
				if (changes.changes.length &gt; 0) {
					setContacts((prev) =&gt; {
						const contactMap = new Map(prev.map((c) =&gt; [c.id, c]));

						changes.changes.forEach((contact) =&gt; {
							const existing = contactMap.get(contact.id);
							if (!existing || existing.updatedAt &lt; contact.updatedAt) {
								contactMap.set(contact.id, contact);
							}
						});

						return Array.from(contactMap.values()).filter(c =&gt; c.name !== &quot;&quot;);
					});
				}

				// Push local changes
				const localChanges = contacts.filter(
					(c) =&gt; c.updatedAt &gt; lastSyncTime.current,
				);
				if (localChanges.length &gt; 0) {
					await contactsActor.connection!.pushChanges(localChanges);
				}

				lastSyncTime.current = changes.timestamp;
				setSyncStatus(&quot;Synced&quot;);

				// Update stats
				const statsData = await contactsActor.connection!.getSyncStats();
				setStats(statsData);
			} catch (error) {
				setSyncStatus(&quot;Offline&quot;);
			}
		};

		syncIntervalRef.current = setInterval(sync, 5000);

		return () =&gt; {
			if (syncIntervalRef.current) {
				clearInterval(syncIntervalRef.current);
				syncIntervalRef.current = null;
			}
		};
	}, [contactsActor.connection, contacts]);

	// Add new contact (local first)
	const addContact = async () =&gt; {
		if (!name.trim()) return;

		const newContact: Contact = {
			id: Date.now().toString(),
			name,
			email,
			phone,
			updatedAt: Date.now(),
		};

		// Add locally first for immediate UI feedback
		setContacts((prev) =&gt; [...prev, newContact]);

		// Then sync to server
		if (contactsActor.connection) {
			try {
				await contactsActor.connection.pushChanges([newContact]);
				const statsData = await contactsActor.connection.getSyncStats();
				setStats(statsData);
			} catch (error) {
				setSyncStatus(&quot;Offline&quot;);
			}
		}

		setName(&quot;&quot;);
		setEmail(&quot;&quot;);
		setPhone(&quot;&quot;);
	};

	// Delete contact (implemented as update with empty name)
	const deleteContact = async (id: string) =&gt; {
		const deletedContact = contacts.find(c =&gt; c.id === id);
		if (!deletedContact) return;

		const updatedContact: Contact = {
			...deletedContact,
			name: &quot;&quot;, // Mark as deleted
			updatedAt: Date.now()
		};

		// Remove locally first for immediate UI feedback
		setContacts((prev) =&gt; prev.filter((c) =&gt; c.id !== id));

		// Then sync to server
		if (contactsActor.connection) {
			try {
				await contactsActor.connection.pushChanges([updatedContact]);
				const statsData = await contactsActor.connection.getSyncStats();
				setStats(statsData);
			} catch (error) {
				setSyncStatus(&quot;Offline&quot;);
			}
		}
	};

	// Manual sync
	const handleSync = async () =&gt; {
		if (!contactsActor.connection) return;

		setSyncStatus(&quot;Syncing&quot;);

		try {
			// Push all contacts
			await contactsActor.connection.pushChanges(contacts);

			// Get all changes
			const changes = await contactsActor.connection.getChanges(0);

			setContacts(changes.changes.filter(c =&gt; c.name !== &quot;&quot;));
			lastSyncTime.current = changes.timestamp;
			setSyncStatus(&quot;Synced&quot;);

			// Update stats
			const statsData = await contactsActor.connection.getSyncStats();
			setStats(statsData);
		} catch (error) {
			setSyncStatus(&quot;Offline&quot;);
		}
	};

	// Reset all data
	const handleReset = async () =&gt; {
		if (!contactsActor.connection) return;

		try {
			await contactsActor.connection.reset();
			setContacts([]);
			lastSyncTime.current = Date.now();
			setSyncStatus(&quot;Synced&quot;);
			setStats({ totalContacts: 0, lastSyncTime: Date.now(), deletedContacts: 0 });
		} catch (error) {
			setSyncStatus(&quot;Offline&quot;);
		}
	};

	// Handle form submission
	const handleSubmit = (e: React.FormEvent) =&gt; {
		e.preventDefault();
		addContact();
	};

	return (
		&lt;div className=&quot;app-container&quot;&gt;
			&lt;div className=&quot;header&quot;&gt;
				&lt;h1&gt;Sync Contacts&lt;/h1&gt;
				&lt;div className=&quot;sync-status&quot;&gt;
					&lt;span className={`status-indicator status-${syncStatus.toLowerCase()}`}&gt;
						{syncStatus}
					&lt;/span&gt;
					&lt;button 
						className=&quot;sync-button&quot; 
						onClick={handleSync}
						disabled={!contactsActor.connection || syncStatus === &quot;Syncing&quot;}
					&gt;
						Sync Now
					&lt;/button&gt;
					&lt;button 
						className=&quot;sync-button&quot; 
						onClick={handleReset}
						disabled={!contactsActor.connection}
						style={{ backgroundColor: &quot;#dc3545&quot; }}
					&gt;
						Reset
					&lt;/button&gt;
				&lt;/div&gt;
			&lt;/div&gt;

			&lt;div className=&quot;info-box&quot;&gt;
				&lt;h3&gt;How it works&lt;/h3&gt;
				&lt;p&gt;
					This contact sync system demonstrates offline-first synchronization with conflict resolution. 
					Add contacts and they&#39;ll sync across all connected clients. The system handles conflicts using 
					&quot;last write wins&quot; based on timestamps, and supports offline operation with automatic sync when reconnected.
				&lt;/p&gt;
			&lt;/div&gt;

			&lt;div className=&quot;add-contact-section&quot;&gt;
				&lt;h3&gt;Add New Contact&lt;/h3&gt;
				&lt;form onSubmit={handleSubmit} className=&quot;contact-form&quot;&gt;
					&lt;input
						type=&quot;text&quot;
						placeholder=&quot;Name *&quot;
						value={name}
						onChange={(e) =&gt; setName(e.target.value)}
						required
						disabled={!contactsActor.connection}
					/&gt;
					&lt;input
						type=&quot;email&quot;
						placeholder=&quot;Email&quot;
						value={email}
						onChange={(e) =&gt; setEmail(e.target.value)}
						disabled={!contactsActor.connection}
					/&gt;
					&lt;input
						type=&quot;tel&quot;
						placeholder=&quot;Phone&quot;
						value={phone}
						onChange={(e) =&gt; setPhone(e.target.value)}
						disabled={!contactsActor.connection}
					/&gt;
					&lt;button 
						type=&quot;submit&quot; 
						className=&quot;add-button&quot;
						disabled={!contactsActor.connection || !name.trim()}
					&gt;
						Add Contact
					&lt;/button&gt;
				&lt;/form&gt;
			&lt;/div&gt;

			&lt;div className=&quot;contacts-list&quot;&gt;
				&lt;h3&gt;Contacts ({contacts.length})&lt;/h3&gt;
				{contacts.length === 0 ? (
					&lt;div className=&quot;empty-state&quot;&gt;
						No contacts yet. Add some contacts to get started!
					&lt;/div&gt;
				) : (
					contacts.map((contact) =&gt; (
						&lt;div key={contact.id} className=&quot;contact-item&quot;&gt;
							&lt;div className=&quot;contact-info&quot;&gt;
								&lt;div className=&quot;contact-name&quot;&gt;{contact.name}&lt;/div&gt;
								&lt;div className=&quot;contact-details&quot;&gt;
									{contact.email &amp;&amp; (
										&lt;div className=&quot;contact-email&quot;&gt;📧 {contact.email}&lt;/div&gt;
									)}
									{contact.phone &amp;&amp; (
										&lt;div className=&quot;contact-phone&quot;&gt;📞 {contact.phone}&lt;/div&gt;
									)}
								&lt;/div&gt;
							&lt;/div&gt;
							&lt;button
								className=&quot;delete-button&quot;
								onClick={() =&gt; deleteContact(contact.id)}
								disabled={!contactsActor.connection}
							&gt;
								Delete
							&lt;/button&gt;
						&lt;/div&gt;
					))
				)}
			&lt;/div&gt;

			&lt;div className=&quot;stats&quot;&gt;
				&lt;div className=&quot;stat-item&quot;&gt;
					&lt;div className=&quot;stat-value&quot;&gt;{stats.totalContacts}&lt;/div&gt;
					&lt;div className=&quot;stat-label&quot;&gt;Total Contacts&lt;/div&gt;
				&lt;/div&gt;
				&lt;div className=&quot;stat-item&quot;&gt;
					&lt;div className=&quot;stat-value&quot;&gt;{stats.deletedContacts}&lt;/div&gt;
					&lt;div className=&quot;stat-label&quot;&gt;Deleted Items&lt;/div&gt;
				&lt;/div&gt;
				&lt;div className=&quot;stat-item&quot;&gt;
					&lt;div className=&quot;stat-value&quot;&gt;
						{stats.lastSyncTime ? new Date(stats.lastSyncTime).toLocaleTimeString() : &quot;—&quot;}
					&lt;/div&gt;
					&lt;div className=&quot;stat-label&quot;&gt;Last Sync&lt;/div&gt;
				&lt;/div&gt;
			&lt;/div&gt;
		&lt;/div&gt;
	);
}
">
<input type="hidden" name="project[files][src/frontend/index.html]" value="&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;Sync Contacts - RivetKit&lt;/title&gt;
    &lt;style&gt;
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 20px;
            background-color: #f0f0f0;
        }
        .app-container {
            max-width: 800px;
            margin: 0 auto;
            background-color: white;
            padding: 30px;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        .header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 30px;
            padding-bottom: 20px;
            border-bottom: 2px solid #e9ecef;
        }
        .header h1 {
            color: #333;
            margin: 0;
        }
        .sync-status {
            display: flex;
            align-items: center;
            gap: 15px;
        }
        .status-indicator {
            padding: 8px 12px;
            border-radius: 4px;
            font-weight: bold;
            font-size: 14px;
        }
        .status-synced {
            background-color: #d4edda;
            color: #155724;
        }
        .status-syncing {
            background-color: #d1ecf1;
            color: #0c5460;
        }
        .status-offline {
            background-color: #f8d7da;
            color: #721c24;
        }
        .sync-button {
            padding: 8px 16px;
            background-color: #007bff;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
        }
        .sync-button:hover {
            background-color: #0056b3;
        }
        .sync-button:disabled {
            background-color: #6c757d;
            cursor: not-allowed;
        }
        .add-contact-section {
            background-color: #f8f9fa;
            border: 1px solid #dee2e6;
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 30px;
        }
        .add-contact-section h3 {
            margin: 0 0 15px 0;
            color: #495057;
        }
        .contact-form {
            display: grid;
            grid-template-columns: 1fr 1fr 1fr auto;
            gap: 10px;
            align-items: center;
        }
        .contact-form input {
            padding: 10px;
            border: 1px solid #ced4da;
            border-radius: 4px;
            font-size: 14px;
        }
        .contact-form input:focus {
            outline: none;
            border-color: #80bdff;
            box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
        }
        .add-button {
            padding: 10px 20px;
            background-color: #28a745;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
            white-space: nowrap;
        }
        .add-button:hover {
            background-color: #218838;
        }
        .add-button:disabled {
            background-color: #6c757d;
            cursor: not-allowed;
        }
        .contacts-list {
            display: flex;
            flex-direction: column;
            gap: 15px;
        }
        .contacts-list h3 {
            margin: 0 0 15px 0;
            color: #495057;
        }
        .contact-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 15px;
            background-color: #f8f9fa;
            border: 1px solid #dee2e6;
            border-radius: 6px;
        }
        .contact-info {
            flex: 1;
        }
        .contact-name {
            font-size: 18px;
            font-weight: bold;
            color: #333;
            margin-bottom: 5px;
        }
        .contact-details {
            display: flex;
            gap: 20px;
            color: #6c757d;
            font-size: 14px;
        }
        .contact-email, .contact-phone {
            display: flex;
            align-items: center;
            gap: 5px;
        }
        .delete-button {
            padding: 8px 16px;
            background-color: #dc3545;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
        }
        .delete-button:hover {
            background-color: #c82333;
        }
        .empty-state {
            text-align: center;
            padding: 40px 20px;
            color: #6c757d;
            font-style: italic;
        }
        .info-box {
            background-color: #e8f4f8;
            border: 1px solid #b8d4da;
            border-radius: 6px;
            padding: 15px;
            margin-bottom: 20px;
        }
        .info-box h3 {
            margin: 0 0 10px 0;
            color: #2c5aa0;
        }
        .info-box p {
            margin: 0;
            color: #555;
            line-height: 1.5;
        }
        .stats {
            display: flex;
            justify-content: space-around;
            margin-top: 20px;
            padding: 15px;
            background-color: #f8f9fa;
            border-radius: 6px;
        }
        .stat-item {
            text-align: center;
        }
        .stat-value {
            font-size: 24px;
            font-weight: bold;
            color: #007bff;
        }
        .stat-label {
            color: #6c757d;
            font-size: 14px;
        }
        @media (max-width: 768px) {
            .contact-form {
                grid-template-columns: 1fr;
                gap: 15px;
            }
            .contact-item {
                flex-direction: column;
                align-items: flex-start;
                gap: 15px;
            }
            .contact-details {
                flex-direction: column;
                gap: 5px;
            }
        }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
    &lt;div id=&quot;root&quot;&gt;&lt;/div&gt;
    &lt;script type=&quot;module&quot; src=&quot;/main.tsx&quot;&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;">
<input type="hidden" name="project[files][src/frontend/main.tsx]" value="import { StrictMode } from &quot;react&quot;;
import { createRoot } from &quot;react-dom/client&quot;;
import { App } from &quot;./App&quot;;

const root = document.getElementById(&quot;root&quot;);
if (!root) throw new Error(&quot;Root element not found&quot;);

createRoot(root).render(
	&lt;StrictMode&gt;
		&lt;App /&gt;
	&lt;/StrictMode&gt;
);">
<input type="hidden" name="project[description]" value="generated by https://pkg.pr.new">
<input type="hidden" name="project[template]" value="node">
<input type="hidden" name="project[title]" value="example-sync">
</form>
<script>document.getElementById("mainForm").submit();</script>

</body></html>