Sync Architecture
The app uses an offline-first sync protocol with real-time collaboration support.
Overview
Core Concepts
Two Sync Mechanisms
| Type | Use Case | Protocol |
|---|---|---|
| WatermelonDB Sync | Structured data (customers, products) | Pull/Push via tRPC |
| Yjs Collaboration | Rich text (notes, descriptions) | WebSocket via Durable Objects |
Timestamp-Based Sync
Every record has created_at and updated_at timestamps. The sync uses lastPulledAt to fetch only changed records.
Soft Deletes
Records are never physically deleted. Instead, a deleted flag is set to 1. This allows deleted records to sync to other clients.
Event-Driven Updates
Instead of polling, the app uses WebSocket notifications:
- SyncRoom: Broadcasts when structured data changes
- YjsRoom: Real-time document collaboration
WatermelonDB Sync Flow
Real-Time Notifications (SyncRoom)
When a client pushes changes, the server broadcasts a notification to all connected clients via WebSocket.
Connection Flow
// Client connects to receive notifications
const ws = new WebSocket(`${API_URL}/sync/subscribe`);
ws.onmessage = (event) => {
const { type, table } = JSON.parse(event.data);
if (type === 'change') {
// Re-sync to get latest data
safeSyncDatabase();
}
};
Server Implementation
// SyncRoom Durable Object
export class SyncRoom {
private sessions: Set<WebSocket> = new Set();
async fetch(request: Request): Promise<Response> {
// Handle WebSocket upgrade
const [client, server] = Object.values(new WebSocketPair());
this.sessions.add(server);
// Handle /broadcast POST
// ... broadcasts to all connected clients
return new Response(null, { status: 101, webSocket: client });
}
}
Yjs Collaboration (YjsRoom)
Rich text fields (Notes, Description) use real-time Yjs collaboration.
Architecture
Client Usage
<YjsProvider roomId={`customer-notes-${customerId}`}>
<CollaborativeLexicalEditor placeholder="Enter notes..." />
</YjsProvider>
Database Schema
-- Structured data (synced via WatermelonDB)
CREATE TABLE Customers (
CustomerId TEXT PRIMARY KEY,
ContactName TEXT,
CompanyName TEXT,
Notes TEXT, -- Legacy field (unused with Yjs)
created_at INTEGER,
updated_at INTEGER,
deleted INTEGER DEFAULT 0
);
CREATE TABLE Products (
ProductId TEXT PRIMARY KEY,
Title TEXT,
Description TEXT, -- Legacy field (unused with Yjs)
Price REAL,
Discounts REAL,
Image TEXT,
created_at INTEGER,
updated_at INTEGER,
deleted INTEGER DEFAULT 0
);
-- Yjs document states (collaborative rich text)
CREATE TABLE YjsRooms (
RoomId TEXT PRIMARY KEY, -- e.g., 'customer-notes-123'
YjsState TEXT, -- Base64-encoded Yjs state
updated_at INTEGER
);
Sync Triggers
Sync is triggered in these scenarios:
- App Startup: Initial sync on load
- After Local Changes:
safeSyncDatabase()called after CRUD operations - WebSocket Notification: When another client pushes changes
- Yjs Updates: Real-time via WebSocket (no manual trigger needed)
// After saving a customer
await database.write(async () => {
await customer.update(record => { ... });
});
await safeSyncDatabase(); // Push to server
Conflict Resolution
| Data Type | Resolution Strategy |
|---|---|
| Structured fields | Last-Write-Wins (by updated_at) |
| Rich text (Yjs) | CRDT merge (all edits preserved) |
Testing Sync
// Test structured sync
await safeSyncDatabase();
console.log('Sync complete!');
// Test real-time collaboration
// 1. Open same customer in two windows
// 2. Edit notes in one window
// 3. See changes appear in other window instantly