Skip to main content
Version: 0.8.0

Sync Architecture

The app uses an offline-first sync protocol with real-time collaboration support.

Overview

Core Concepts

Two Sync Mechanisms

TypeUse CaseProtocol
WatermelonDB SyncStructured data (customers, products)Pull/Push via tRPC
Yjs CollaborationRich 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:

  1. App Startup: Initial sync on load
  2. After Local Changes: safeSyncDatabase() called after CRUD operations
  3. WebSocket Notification: When another client pushes changes
  4. 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 TypeResolution Strategy
Structured fieldsLast-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