What is Replicate?
mainop-sqlite on React Native). It uses Yjs CRDTs to synchronize changes through Convex, handling conflict resolution automatically.repository·main·Indexed 42 days ago
https://github.com/trestleinc/replicateA 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.
op-sqlite on React Native). It uses Yjs CRDTs to synchronize changes through Convex, handling conflict resolution automatically.Avoid the two extremes of network synchronization:
syncFn().catch(...)). This leads to lost errors and no retry logic.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:
The architecture uses a dual-storage pattern to balance standard Convex CRUD with offline synchronization capabilities.
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.
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.
Replicate uses a layered security model to separate identity, visibility, and data protection:
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.
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 backThe synchronization system relies on three primary Convex endpoints:
delta (Query):
{ seq, limit? }): Reactively returns CRDT changes since a specific sequence number.{ document, vector }): Used on startup to fetch only missing deltas based on a local Yjs state vector.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.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.The Read Path describes how the client receives updates from the server via WebSockets.
onUpdate(stream, { cursor }).SELECT * FROM documents WHERE seq > cursor.collection.ts applies the updates using Y.applyUpdate.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 vectorA 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.clientIDPersistenceGate 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.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:
status = 'in_progress', User B sets status = 'done'. User B's value wins; User A's change is lost.'done' always wins over 'in_progress').The Replicate sync system uses a client-server architecture designed for real-time collaborative editing using Yjs.
Client Side:
Y.Doc.clientID and State Vector.SQLite for local persistence.Effect.ts and Actors.Server Side (Convex):
documents (deltas), snapshots, and sessions.COMPACTION LOGIC to merge deltas into snapshots.┌─────────────────────────────────────────────────────────────────────────┐
│ SYSTEM ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────────────────────────────────┐ │
│ │ CLIENT │ │ SERVER (Convex) │ │
│ ├─────────────┤ ├─────────────────────────────────────────┤ │
│ │ │ │ │ │
│ │ Y.Doc │◄───────►│ ┌─────────┐ ┌──────────┐ ┌────────┐ │ │
│ │ clientID │ sync │ │documents│ │ snapshots│ │sessions│ │ │
│ │ │ WS │ │ (deltas)│ │ │ │ │ │ │
│ │ State │ │ └────┬────┘ └────┬─────┘ └───┬────┘ │ │
│ │ Vector │────────►│ │ │ │ │ │
│ │ │ heartbeat │ │ │ │ │ │
│ │ SQLite │ │ └────────────┼────────────┘ │ │
│ │ (persist) │ │ │ │ │
│ │ │ │ ┌─────┴─────┐ │ │
│ │ Effect.ts │ │ │COMPACTION │ │ │
│ │ Actors │ │ │ LOGIC │ │ │
│ └─────────────┘ │ └───────────┘ │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘