Sync protocol
Synchronize logical row changes with snapshots, opaque cursors, push, and pull.
Move changes between server and replica
Sync protocol documents RLS-filtered snapshots, mutation pushes, logical pulls, and scope controls.
The protocol enables offline replicas without copying SQLite WAL frames or weakening server authorization.
Use the low-level API when the application owns a durable transactional replica and retry queue.
Requirements for Sync protocol
- Prerequisite — An authenticated project session, stable primary keys, and sync-compatible schema.
- Prerequisite — A replica store that can atomically replace rows, cursor, and pending state.
- Required value — Opaque cursor, schema version, mutation ID, table, primary key, operation, values, base row version, and batch limits.
- Required value — A stable cache key for the verified auth scope.
Snapshot and pull
A snapshot returns RLS-visible tables at a consistent schema version and server sequence. Pull returns later logical changes and a replacement opaque cursor. Cursors are authenticated, bounded, project/schema/scope bound, and must not be parsed or logged.
let snapshot = await ffdb.sync.snapshot(["documents"]);
let cursor = snapshot.cursor;
const pull = await ffdb.sync.pull(cursor, 1000);
if (pull.control?.type === "resnapshot_required" ||
pull.control?.type === "invalidate_scope") {
snapshot = await ffdb.sync.snapshot(["documents"]);
cursor = snapshot.cursor;
} else {
for (const change of pull.changes) {
await applyLogicalChange(change);
}
cursor = pull.cursor;
}Push and controls
- Every mutation has a unique mutation id and base row version.
- Server sequence—not client time—orders last-write-wins conflicts.
- Accepted mutations commit independently and return per-item results.
- Schema, policy, scope, or retention changes can return resnapshot_required or invalidate_scope.
const result = await ffdb.sync.push({
schema_version: snapshot.schema_version,
mutations: [{
mutation_id: mutationId,
table: "documents",
primary_key: documentId,
operation: "update",
values: { title: nextTitle },
base_row_version: currentRowVersion,
client_timestamp_ms: Date.now(),
}],
});
for (const mutation of result.results) {
if (mutation.status === "rejected") {
await moveToRejectedQueue(mutation.mutation_id, mutation.error_code);
}
}Choose the API level
- Use ffdb.sync.snapshot/pull/push when your application already owns a transactional replica engine and retry queue.
- Use OfflineSyncClient when you want FFDB's tested snapshot → push → pull orchestration around a ReplicaAdapter.
- Use IndexedDbReplica in browsers and NodeSQLiteReplica in Node 24+ for first-party durable local state.
- Use NativeSQLiteReplica on React Native after wrapping the runtime's SQLite transaction and execute APIs.
- Use MemoryReplica only for tests and short-lived demonstrations; it is not durable across restarts.
Sync protocol workflow
- 1. Fetch a snapshot when no valid cursor exists.
- 2. Queue local mutations with unique IDs.
- 3. Push bounded batches and consume every per-item result.
- 4. Pull changes from the pre-push cursor until has_more is false.
- 5. Replace the scoped replica on invalidate_scope or resnapshot_required.
Verify sync protocol
The replica converges on server-authoritative logical rows and retains rejected mutations for user-visible resolution.
Troubleshoot sync protocol
- A cursor is rejected or invalidated — discard the affected scoped rows and atomically resnapshot.
- A mutation ID is reused with different content — reject the local operation and generate a new stable ID.
Continue from Sync protocol
- Use OfflineSyncClient for maintained orchestration.
- Review conflict behavior and cache-scope rules.