Sync client
Use the browser, Node, or custom transactional replica behind @ffdb/sync-client.
Orchestrate offline synchronization
The sync-client SDK package provides OfflineSyncClient orchestration and the ReplicaAdapter contract.
It centralizes snapshot-push-pull ordering and supplies durable browser and Node adapters while retaining a custom adapter contract.
Use it when low-level sync is needed but the app should not reimplement batching, receipts, controls, and state publication.
Requirements for Sync client
- Prerequisite — Version-matched @ffdb/client and @ffdb/sync-client packages.
- Prerequisite — IndexedDB in a supported browser, Node 24+ for built-in SQLite, NativeSQLiteReplica on React Native, or another transaction-tested adapter.
- Required value — Replica adapter, push batch 1–100, pull batch 1–1,000, optional clock, mutation values, and AbortSignal.
- Required value — A listener for idle, snapshot, push, pull, and error phases.
Install the runtime-neutral package
Install @ffdb/sync-client from npm at the same version as @ffdb/client and the server. The matching GitHub Release contains a signed tarball for offline installation. Browser and Node adapters are subpath exports of this package.
VERSION=0.3.0
npm install --save-exact "@ffdb/client@$VERSION" \
"@ffdb/sync-client@$VERSION"Use the bundled browser and Node adapters
IndexedDbReplica and NodeSQLiteReplica atomically persist rows, cursor movement, pending work, rejection bookkeeping, and optimistic writes. Both expose deterministic typed getRow and listRows reads. The Node adapter uses the Node 24 built-in node:sqlite module, so no native npm add-on is installed.
import { OfflineSyncClient } from "@ffdb/sync-client";
import { IndexedDbReplica } from "@ffdb/sync-client/browser";
// Never share this database name across users or authorization scopes.
const replica = new IndexedDbReplica(`ffdb-${projectId}-${userId}`);
export const sync = new OfflineSyncClient(ffdb, replica);import { OfflineSyncClient } from "@ffdb/sync-client";
import { NodeSQLiteReplica } from "@ffdb/sync-client/node";
const replica = new NodeSQLiteReplica(
"/var/lib/my-app/ffdb-project-user.sqlite3",
);
export const sync = new OfflineSyncClient(ffdb, replica);
process.once("SIGTERM", () => void replica.close());Adapter responsibilities
- Atomically replace a snapshot, replay still-pending optimistic edits, and persist its cursor.
- Apply ordered authoritative upserts and tombstone deletes.
- Atomically enqueue each pending mutation with its optimistic row insert, partial update, or delete.
- Read one row by primary key or list one table in deterministic primary-key order.
- Move rejected mutations aside with a stable error code and rejection timestamp.
- Destroy stale scoped rows when resnapshot is required.
Implement the current adapter contract
The transaction callback is the atomic boundary. A failed callback must roll back its row, cursor, pending, and rejection changes together. enqueue must persist the pending record and apply its optimistic row change in that same boundary; do not emulate either workflow with unrelated storage writes.
import type {
PendingMutation,
RejectedMutation,
ReplicaRecord,
} from "@ffdb/sync-client";
import type { JsonValue, SnapshotResponse } from "@ffdb/client";
export interface ReplicaAdapter {
transaction<T>(
work: (transaction: ReplicaTransaction) => Promise<T>,
): Promise<T>;
getCursor(): Promise<{
readonly cursor: string;
readonly schemaVersion: number;
} | null>;
getRow(
table: string,
primaryKey: JsonValue,
): Promise<ReplicaRecord | null>;
listRows(table: string): Promise<readonly ReplicaRecord[]>;
getPending(limit: number): Promise<readonly PendingMutation[]>;
getRejected(limit: number): Promise<readonly RejectedMutation[]>;
enqueue(mutation: PendingMutation): Promise<void>;
}
export interface ReplicaTransaction {
getRow(
table: string,
primaryKey: JsonValue,
): Promise<ReplicaRecord | null>;
getPending(limit: number): Promise<readonly PendingMutation[]>;
upsert(record: ReplicaRecord): Promise<void>;
delete(
table: string,
primaryKey: JsonValue,
rowVersion: number,
serverSequence: number,
): Promise<void>;
replaceSnapshot(snapshot: SnapshotResponse): Promise<void>;
setCursor(cursor: string, schemaVersion: number): Promise<void>;
clearCursor(): Promise<void>;
removePending(mutationIds: readonly string[]): Promise<void>;
rejectPending(mutationId: string, errorCode: string): Promise<void>;
}Queue a mutation
The local row changes as soon as durable enqueue commits. Inserts replace local values, updates merge supplied fields, and deletes remove the visible row. client_timestamp_ms is diagnostic only; the server sequence remains authoritative, and a reused mutation id with different content is rejected.
import { generateId } from "@ffdb/client";
await sync.mutate({
mutation_id: generateId("mut_"),
table: "documents",
primary_key: documentId,
operation: "update",
values: { title: nextTitle },
base_row_version: currentRowVersion,
client_timestamp_ms: Date.now(),
});
const visibleImmediately = await sync.getRow("documents", documentId);
const pending = await sync.getPending();
await sync.sync();Understand one sync run
- With no cursor, snapshot first and atomically replace visible rows plus cursor and schema version.
- Push pending mutations in batches of 1–100 and consume exactly one result per mutation.
- Applied, duplicate, and superseded results leave the pending queue; rejected results move to the rejected queue.
- Duplicate, superseded, and rejected results atomically invalidate the old cursor and force an authoritative snapshot, so interrupted recovery resumes safely and an optimistic value cannot survive without a matching server change.
- Keep the pre-push cursor so the pull observes server-authoritative changes from accepted mutations.
- Pull batches of 1–1,000 until has_more is false; an invalidate_scope or resnapshot_required control replaces the scoped replica.
- sync() deduplicates concurrent callers. Abort signals stop HTTP work, while the durable queue remains the retry source of truth.
Sync client workflow
- 1. Choose IndexedDbReplica, NodeSQLiteReplica, NativeSQLiteReplica, or implement and transaction-test a custom adapter.
- 2. Construct OfflineSyncClient with bounded options.
- 3. Enqueue a mutation with a unique valid ID.
- 4. Call sync and observe the phase sequence.
- 5. Inspect rejected work and retry only according to its stable error.
Verify sync client
Concurrent sync calls share one run and durable state advances atomically through snapshot, push, and pull.
Troubleshoot sync client
- The server returns missing or duplicate mutation results — fail the run without deleting pending work.
- The adapter loses pending work after restart — replace it before claiming offline durability.
Continue from Sync client
- Integrate lifecycle scheduling.
- Run the offline runtime acceptance matrix.