Replicate Sync Engine

repository·main·Indexed 42 days ago

https://github.com/trestleinc/replicate

A local-first sync engine for Convex that provides instant reads and offline writes. It utilizes client-side SQLite (OPFS or op-sqlite) and Yjs CRDTs for conflict-free synchronization, employing a dual-storage pattern to balance query performance with real-time collaboration.

Tokens
76.4K
Snippets
198
Records
261
Agent score
54%

What's inside Replicate

  1. What is Replicate?

    main
    Replicate is a local-first sync engine for Convex. It enables instant reads, offline writes, and real-time collaboration by maintaining a full copy of your data in client-side SQLite (using OPFS on web or op-sqlite on React Native). It uses Yjs CRDTs to synchronize changes through Convex, handling conflict resolution automatically.
  2. Use Background Task Queues instead of Fire-and-Forget or Blocking Sync

    main

    Avoid the two extremes of network synchronization:

    1. Fire-and-forget: Calling a sync function without awaiting it (e.g., syncFn().catch(...)). This leads to lost errors and no retry logic.
    2. Blocking sync: Using await syncFn() in the main execution path. This causes UI freezes and poor user experience during network latency.

    Solution: Implement a Background Task Queue. This allows the UI to remain responsive while the queue handles operations in the background with the following properties:

    • FIFO order per document.
    • Exponential backoff on failure.
    • Coalescing: New tasks can replace pending tasks for the same resource.
    • Observable state: The UI can show pending indicators without being blocked.
  3. Understand the Local-First Architecture (v2) mental model

    main

    The architecture uses a dual-storage pattern to balance standard Convex CRUD with offline synchronization capabilities.

    1. Main Table: This is your primary application data. Online clients interact with this table directly using standard Convex queries, mutations, and subscriptions. It is the single source of truth for the online experience.
    2. Deltas Table: This acts as an append-only operation log. It records every insert, update, and delete as a Yjs delta (or a synthetic marker). This table is not used during normal online operation; it is exclusively used by clients to recover and synchronize state after being offline.

    When a client is online, it follows the standard Convex flow. When a client reconnects after being offline, it compares its local state to the Main Table, identifies out-of-sync documents, and requests missing deltas from the Deltas Table to resolve conflicts via Yjs.

  4. How client identity is managed across tabs

    main

    To ensure consistent synchronization, multiple browser tabs must be treated as the same logical client. This is achieved by persisting the Y.Doc.clientID in shared storage (such as localStorage or a shared SQLite instance).

    When tabs share the same Y.Doc state via shared storage, they also share the same clientID. This is a safe pattern because Yjs warnings regarding duplicate clientIDs typically apply to different Y.Doc instances; since these tabs are working on the same document, they are considered a single session.

  5. Understand Replicate's layered security model

    main

    Replicate uses a layered security model to separate identity, visibility, and data protection:

    • auth: Defines who you are (handled by external providers like Clerk, WorkOS, or Google OAuth).
    • view: A server-side gate defining what you can see.
    • hooks: A server-side gate defining what you can write.
    • encryption: Client-side data protection that works offline.

    Key Insight: Authentication is decoupled from encryption. This allows for passwordless encryption via biometrics (Touch ID/Face ID) and offline access to encrypted data even if the auth provider is unreachable.

  6. Implement Transactional Rollback for Optimistic Mutations

    main

    When performing optimistic updates (applying local changes before server confirmation), do not use manual 'rollback' logic (e.g., calling a clearDeleted function in a .catch block). Manual rollbacks are prone to race conditions where a subscription update might be skipped during the mutation, leaving the client in a stale state even after the 'rollback' completes.

    Best Practice: Use a transactional boundary where changes are staged and only applied upon a successful commit. If the server call fails, the entire transaction should be automatically rolled back to the previous known good state, similar to the TanStack DB pattern.

    // Recommended pattern: Staged changes with automatic rollback
    const tx = createTransaction({
      mutationFn: async () => {
        await fetch('/api/delete', { ... });
      }
    });
    
    tx.mutate(() => {
      collection.delete("doc-1"); // Staged, not applied yet
    });
    
    await tx.commit(); // Only applied if mutationFn succeeds; otherwise, rolled back
  7. Understand the Replicate Sync Protocol

    main

    The synchronization system relies on three primary Convex endpoints:

    1. delta (Query):

      • Streaming mode ({ seq, limit? }): Reactively returns CRDT changes since a specific sequence number.
      • Recovery mode ({ document, vector }): Used on startup to fetch only missing deltas based on a local Yjs state vector.
    2. replicate (Mutation): Handles all write operations. The client sends one of the following shapes:

      • { type: 'insert', document: id, bytes: crdtUpdate, material: { ...fields } }
      • { type: 'update', document: id, bytes: crdtUpdate, material: { ...fields } }
      • { type: 'delete', document: id, bytes: crdtUpdate } The server runs authorization, updates the Convex table, stores the delta, and triggers side-effect hooks.
    3. presence (Mutation): Manages session state via four actions:

      • join: Register a session.
      • leave: Deregister a session.
      • mark: Report sync progress (current seq/vector).
      • signal: Heartbeat and cursor/profile updates.
  8. Understand the Replicate Read Path (Server to Client)

    main

    The Read Path describes how the client receives updates from the server via WebSockets.

    1. Subscription: The client subscribes via WebSocket using onUpdate(stream, { cursor }).
    2. Query: The server queries for new changes: SELECT * FROM documents WHERE seq > cursor.
    3. Application: Upon receiving changes, collection.ts applies the updates using Y.applyUpdate.
    4. Persistence: The updates are upserted/inserted/deleted into the local TanStack DB.
    5. Notification: The actorManager.onServerUpdate(documentId) is called, and the Actor receives an ExternalUpdate to update its stored vector for bookkeeping.
    /* Conceptual flow of the Read Path */
    // 1. Subscribe: onUpdate(stream, { cursor })
    // 2. Server Query: SELECT * FROM documents WHERE seq > cursor
    // 3. Receive changes
    // 4. collection.ts applies Y.applyUpdate
    // 5. ops.upsert/insert/delete to TanStack DB
    // 6. actorManager.onServerUpdate(documentId)
    // 7. Actor receives ExternalUpdate
    // 8. Update stored vector
  9. Understand Yjs ClientID requirements

    main

    A ClientID is a unique 53-bit integer per Y.Doc instance that identifies which client created a specific operation.

    Critical Rule: You must ensure that no two active Y.Doc instances share the same ClientID. Identical ClientIDs across different document states can cause document corruption.

    When it is safe to persist a ClientID: It is acceptable to persist and reuse a ClientID if all instances sharing that ID also share the exact same Y.Doc state. This is common when multiple browser tabs share the same underlying storage (e.g., localStorage or SQLite). In this scenario, shared storage implies the same client, making the shared ClientID correct.

    Y.Doc.clientID
  10. How PersistenceGate manages async initialization

    main
    Because PGlite (PostgreSQL in the browser via IndexedDB) requires asynchronous initialization, you should use a PersistenceGate component. This component blocks the rendering of the application until the persistence layer is fully ready, ensuring that offline-first functionality is established before any data access or queries are attempted.
  11. What are Sync Rules and CRDT Types?

    main

    Sync rules define how specific fields in your schema handle concurrent edits using Conflict-free Replicated Data Types (CRDTs).

    Unlike standard Last-Write-Wins (LWW) behavior—where the most recent timestamp simply overwrites previous data—CRDTs allow multiple users to edit the same field simultaneously without losing data. The changes are merged based on specific logic (e.g., summing increments, unioning sets, or resolving status priorities).

    Key Differences:

    • LWW: User A sets status = 'in_progress', User B sets status = 'done'. User B's value wins; User A's change is lost.
    • CRDT: Both changes are preserved and resolved via the type's specific logic (e.g., a priority rule where 'done' always wins over 'in_progress').
  12. Understand the Replicate Sync System Architecture

    main

    The Replicate sync system uses a client-server architecture designed for real-time collaborative editing using Yjs.

    Client Side:

    • Maintains a Y.Doc.
    • Tracks a clientID and State Vector.
    • Uses SQLite for local persistence.
    • Manages Effect.ts and Actors.
    • Communicates via WebSocket (WS) sync and heartbeats.

    Server Side (Convex):

    • Manages three primary data collections: documents (deltas), snapshots, and sessions.
    • Runs COMPACTION LOGIC to merge deltas into snapshots.
    • Orchestrates synchronization between clients and the persistent state.
    ┌─────────────────────────────────────────────────────────────────────────┐
    │                         SYSTEM ARCHITECTURE                              │
    ├─────────────────────────────────────────────────────────────────────────┤
    │                                                                          │
    │  ┌─────────────┐         ┌─────────────────────────────────────────┐    │
    │  │   CLIENT    │         │              SERVER (Convex)             │    │
    │  ├─────────────┤         ├─────────────────────────────────────────┤    │
    │  │             │         │                                         │    │
    │  │  Y.Doc      │◄───────►│  ┌─────────┐  ┌──────────┐  ┌────────┐ │    │
    │  │  clientID   │   sync   │  │documents│  │ snapshots│  │sessions│ │    │
    │  │             │   WS     │  │ (deltas)│  │          │  │        │ │    │
    │  │  State      │         │  └────┬────┘  └────┬─────┘  └───┬────┘ │    │
    │  │ Vector      │────────►│       │            │            │      │    │
    │  │             │ heartbeat │       │            │            │      │    │
    │  │  SQLite     │         │       └────────────┼────────────┘      │    │
    │  │ (persist)   │         │                    │                   │    │
    │  │             │         │              ┌─────┴─────┐             │    │
    │  │  Effect.ts  │         │              │COMPACTION │             │    │
    │  │  Actors     │         │              │  LOGIC    │             │    │
    │  └─────────────┘         │              └───────────┘             │    │
    │                           └─────────────────────────────────────────┘    │
    │                                                                          │
    └─────────────────────────────────────────────────────────────────────────┘