@ffdb/sync-client
v0.3.14
Published
Runtime-neutral FFDB logical offline-sync orchestration
Maintainers
Readme
@ffdb/sync-client
Runtime-neutral orchestration for FFDB's logical offline-sync protocol. The
package is ESM and its runtime code uses standard JavaScript only, so the same
OfflineSyncClient class can run in a modern browser, React Native, or Node.js.
The runtime must also support @ffdb/client (or supply its fetch
implementation) and an authenticated end-user session.
pnpm add --save-exact @ffdb/[email protected] @ffdb/[email protected]The matching GitHub Release also provides a checksum-listed
ffdb-sync-client-0.3.14.tgz for verified offline installation.
import { FFDBClient, MemorySessionStore } from "@ffdb/client";
import { OfflineSyncClient } from "@ffdb/sync-client";
import { IndexedDbReplica } from "@ffdb/sync-client/browser";
const api = new FFDBClient({
baseUrl: "https://data.example.com",
projectId: "your-project-id",
sessionStore: new MemorySessionStore("example"),
});
await api.auth.signIn(email, password);
const replica = new IndexedDbReplica(`ffdb-${projectId}-${userId}`);
const sync = new OfflineSyncClient(api, replica);
await sync.sync();
const note = await sync.getRow("notes", noteId);
const notes = await sync.listRows("notes");MemoryReplica is useful for tests, examples, and process-lifetime caches. It is
not durable: a reload, app termination, or Node process exit loses its rows,
pending mutations, rejected mutations, and cursor.
What the orchestrator does
One sync() run is single-flight and performs these steps:
- Fetch and transactionally replace the replica from an RLS-filtered snapshot when no cursor exists.
- Push queued mutations in batches of at most 100 and transactionally remove or reject each pending record from the server's per-mutation result.
- Pull from the pre-push cursor so server-authoritative changes produced by the accepted local mutations are stored in the replica.
- Apply ordered upserts/deletes and the replacement cursor in one adapter
transaction, continuing while
has_moreis true. - Replace the snapshot on
resnapshot_requiredorinvalidate_scope.
mutate() validates a mutation, then atomically queues it and applies its
insert, partial update, or delete to the visible local rows. getRow() and
listRows() therefore reflect an edit as soon as durable enqueue succeeds.
Server row version and sequence metadata remain authoritative: applied writes
are replaced by the following pull, while duplicate, superseded, and rejected
outcomes atomically invalidate the old cursor and trigger a fresh snapshot so
optimistic state cannot survive when no new logical change exists. If that
recovery snapshot is interrupted, the missing cursor forces the next sync to
retry it. Pending edits are replayed over any snapshot taken before they are
pushed.
The client exposes idle, snapshot, push, pull, and error phases through
state and subscribe(). lastChangedAtMs advances only when a pull or
replacement snapshot changes the replica, so a UI can reload its local view
without rerendering on every idle wait. An AbortSignal cancels the active
network work. A second concurrent sync() call joins the first call and
therefore uses the first call's signal.
Automatic sync
Automatic sync is opt-in and runtime-neutral:
const live = sync.startAutoSync();
// React Native / Expo lifecycle integration:
const subscription = AppState.addEventListener("change", (state) => {
live.setActive(state === "active");
});
// Optional connectivity integration (for example, NetInfo):
live.setOnline(isOnline);
// Graceful shutdown or component cleanup:
subscription.remove();
live.stop();The default controller syncs immediately, debounces optimistic mutations for
250 ms, waits up to 25 seconds for an authenticated server change hint, and
falls back to a 15-second poll when connected to an older FFDB server that
returns the wait immediately. Failures use bounded exponential backoff with
jitter. Inactive/offline controllers pause, a focus/online wake catches up
immediately, concurrent work is deduplicated, and Node timers are unref()ed.
The cursor pull remains authoritative and RLS-filtered; a wake hint never
contains row data or bypasses authorization.
Runtime matrix
| Runtime | Does OfflineSyncClient run? | Bundled replica | Required application setup |
| --- | --- | --- | --- |
| Browser | Yes, in modern ESM/fetch runtimes | IndexedDbReplica from @ffdb/sync-client/browser | Use a database name scoped to the project and signed-in user; delete or rotate it when that authorization scope is removed |
| React Native / Expo | Yes, with compatible fetch, URL, Headers, and AbortController globals | MemoryReplica; durable NativeSQLiteReplica is exported by @ffdb/react-native | Supply a secure session store and a NativeSQLiteDriver wrapper for the chosen SQLite library |
| Node.js | Yes in ESM Node 24+ | NodeSQLiteReplica from @ffdb/sync-client/node | Give each project/user authorization scope its own protected SQLite path and close the replica during graceful shutdown |
The browser and Node adapters persist snapshots, cursors, pending mutations, and
rejections transactionally. All bundled replicas expose the same deterministic
getRow(table, primaryKey) and listRows(table) read surface, plus bounded
getPending(limit) and getRejected(limit) bookkeeping reads.
NodeSQLiteReplica uses Node 24's built-in
node:sqlite; it does not add a native npm dependency. Importing the core
package does not open a database or start background work until the application
constructs a replica and calls startAutoSync(). React DOM applications can use
useAutoSync from @ffdb/react for visibility, focus, and browser connectivity
integration; React Native and Node applications use the same controller
directly.
import { NodeSQLiteReplica } from "@ffdb/sync-client/node";
const replica = new NodeSQLiteReplica("/var/lib/my-app/ffdb-user.sqlite3");
const sync = new OfflineSyncClient(api, replica);
const live = sync.startAutoSync();
try {
// Service work runs while the process is active. Local mutations wake it.
} finally {
live.stop();
await replica.close();
}Replica adapter contract
A persistent adapter must guarantee:
transaction()commits all callback writes atomically and rolls them all back when the callback rejects;- snapshot rows and cursor are replaced in one transaction;
- pulled changes and cursor advance in one transaction;
- pending removal/rejection and push-result processing are atomic;
- enqueue and its optimistic row insert/update/delete are atomic;
- cursor values remain opaque and are never parsed, logged, or constructed;
getRow()returns one decoded record ornull, whilelistRows()returns only the requested table in canonical primary-key JSON order;getPending(limit)is deterministic and returns at mostlimitrecords;getRejected(limit)is deterministic and retains the stable error code and rejection timestamp needed for user-visible resolution;- enqueueing the same mutation ID and payload is idempotent, while reusing the ID for different content is rejected;
- local data is isolated and protected according to the runtime's threat model.
The local read surface deliberately stops at primary-key lookup and deterministic
table listing. It does not expose the private SQLite/IndexedDB connection or
accept arbitrary local SQL. Filter, group, and index the returned typed records
in application code, or implement a custom ReplicaAdapter when the product
needs a richer safe read model.
Boundaries
- Sync is end-user-only and always remains subject to the server's current RLS.
- Server sequence, not client time, orders conflicts.
- Mutation batches contain 1–100 records; pull batches contain 1–1,000 changes.
- Rejected mutations move to adapter rejection bookkeeping and are readable
through
getRejected(). This package does not provide conflict-resolution UI or an automatic retry policy for them. - The package does not cache arbitrary
FFDBClient.query()results, rewrite SQL, expose a local SQLite connection, encrypt local storage, or migrate an application's own schema.
