FFDB Docs

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.

low-level-sync.tsts
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.
push.tsts
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.