Skip to main content

Sync

The sync runtime keeps .synced() tables in step with your server. It pulls rows, records the user's edits in an outbox, pushes them, and makes sure that neither a pull nor a push ever throws away what the user did.

import { createSyncRuntime } from 'declarative-sqlite';

const sync = await createSyncRuntime({ db, transport, deviceId });

Create it once, right after Database.open. Call sync.close() before db.close().

The transport​

You provide the network part as two functions. What they call is up to you; Server protocol describes what the answers must contain.

import type { SyncTransport, RowsPage, PushResult } from 'declarative-sqlite';

// HttpError and authHeaders() stand in for your own code.

const transport: SyncTransport = {
async pullRows(req) {
// req = { table: 'TASK', scope?: 'PROJECT_ID:42', after: 1200, limit?: 500 }
const params = new URLSearchParams({ table: req.table, after: String(req.after) });
if (req.scope) params.set('scope', req.scope);
if (req.limit) params.set('limit', String(req.limit));
const res = await fetch(`/sync/rows?${params}`, { headers: authHeaders() });
if (!res.ok) throw new HttpError(res.status);
return (await res.json()) as RowsPage;
},
async push(batch) {
const res = await fetch('/sync/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(batch),
});
if (!res.ok) throw new HttpError(res.status);
return (await res.json()) as PushResult;
},
};

If pullRows throws, the pull rejects. If push throws, the batch is retried later (see Pushing).

Pulling​

const report = await sync.pull.pull('task', { project_id: 42 });
// { rows: 37, pages: 1, cursor: 1288 }

A pull fetches pages until the server says there are no more, and applies each page in one transaction. The runtime stores a cursor per table and scope: the highest sequence number seen so far. The next pull asks only for rows changed after it.

from controls where a pull starts:

fromStarts atUse it for
'cursor' (default)The stored cursorNormal incremental pulls
'window'1000 below the cursorPulls triggered by a change notification (see Ticks)
0The beginningA manual full refresh of that scope

Pulling a row you already have is harmless: rows are upserted by id.

A pulled row with removed: true is deleted locally. Server columns your schema doesn't declare are ignored, so the server can add columns before clients know about them.

A pull never overwrites the user's work:

  • A column with an unconfirmed outbox entry keeps the local value.
  • A column someone is typing in is held. The server value is applied when the draft ends, if the user didn't change the field.
  • A deletion of a row someone is typing in waits until they're done.

Recording changes​

Edits to synced rows go through the outbox:

const groupId = await sync.outbox.record({
table: 'task',
systemId: row.system_id,
changes: { hours: 3.5, title: 'Install pump' },
});

record writes the new values into the local row and adds one outbox entry per column, in a single transaction. Live queries show the new values straight away, and a push is scheduled.

The columns in one record call form a change group: they're always sent in the same push batch.

record throws OutboxError, and records nothing, when:

  • the table isn't synced (write it through db.tables instead),
  • the row doesn't exist locally,
  • a column isn't in the schema,
  • changes is empty, or has more than 500 columns.

It throws ValueTooLongError when a value encodes to more than 4000 JSON characters.

:::info Creating and deleting rows The outbox changes columns of rows that already exist. Creating a synced row or deleting one isn't part of the protocol: new and removed rows come from the server through a pull. :::

Pushing​

Pushes happen on their own: about 2 seconds after the last record, pending entries are sent in batches of up to 500 changes. A change group is never split across two batches.

await sync.push.pushNow(); // push immediately, e.g. from a "Sync" button

Each outbox entry moves through these states:

StatusMeaning
pendingRecorded, not sent yet
sendingIn a batch waiting for an answer
appliedThe server applied it
noopThe server already had that value
rejectedThe server refused it; errorText says why

The server's answer includes the current state of every row the batch touched, which is written locally. An answer row older than what a pull already brought in (a lower sequence number) is skipped.

Network failures and retries​

If push throws, the batch goes back to pending and is retried with the same batch id, so a server that already applied it can just return its stored answer. Retries wait 5 s, then 30 s, then every 2 minutes. Tell the runtime when the connection comes back to retry at once:

window.addEventListener('online', () => sync.push.notifyOnline());

Some errors shouldn't be retried, such as a 400 from a malformed request. Pass isTerminalError, and the whole batch is marked rejected instead:

const sync = await createSyncRuntime({
db,
transport,
deviceId,
isTerminalError: (error) => error instanceof HttpError && error.status >= 400 && error.status < 500,
});

Rejected changes​

A rejected entry stays in the outbox until the user deals with it:

sync.push.onRejected((entry) => toast(`${entry.columnName}: ${entry.errorText}`));

const rejected = await sync.outbox.entries({ status: 'rejected' });
await sync.outbox.retry(rejected[0].id); // back to pending, sent again
await sync.outbox.discard(rejected[0].id); // drop it

After a rejection the local row still holds the value the user entered. It returns to the server's value when that row is next pulled with newer data. To fetch it now, pull the scope again with { from: 0 }.

Status​

sync.push.status();
// { online: true, sending: false, attempt: 0, nextRetryAt: null, lastError: null }

const stop = sync.push.onStatusChange((status) => updateHeader(status));
const counts = await sync.outbox.counts(); // { pending, sending, rejected }

In React, use useSyncStatus() and useOutboxCounts().

Change notifications (ticks)​

If your server can notify clients when a table changes (WebSockets, SignalR, server-sent events), pass those notifications to the runtime:

socket.on('TableChanged', (msg) => {
sync.ticks.notify({ table: 'task', seq: msg.seq, scopes: msg.projectIds });
});

Ticks are collected for about 1.5 seconds, then resolved together. For each table, every scope the app currently has open is pulled once, using from: 'window'. Scopes are skipped if their cursor is already at or past the tick's seq, or if the tick lists scopes and doesn't mention them.

Tell the runtime which scopes are on screen:

const unregister = sync.pull.registerScope('task', { project_id: 42 });
// when the view closes:
unregister();

sync.ticks.flush() resolves pending ticks immediately.

Outbox history​

Settled entries (applied, noop) stay in the outbox table as history. On startup the runtime deletes settled entries older than retentionDays (default 30). Pass retentionDays: 0 to turn that off and call sync.outbox.purgeOlderThan(days) yourself. Rejected entries are never purged automatically.

Options​

OptionDefaultMeaning
dbrequiredThe open Database
transportrequiredYour SyncTransport
deviceIdrequiredSent with every push; use a stable id per installation
debounceMs2000Delay between the last record and the push
maxChangesPerBatch500Changes per push batch (500 is also the maximum)
pullWindow1000How far from: 'window' rewinds
pageLimit–Page size sent to pullRows. Left out, the server picks
tickWindowMs1500How long ticks are collected
retentionDays30Settled outbox history to keep
isTerminalErrorevery error retriesWhich push errors mark a batch rejected instead of retrying
clock() => new Date()Time source, useful in tests

What the runtime contains​

createSyncRuntime returns these services. Most apps only need the first four:

PropertyUse
pullpull(), registerScope()
outboxrecord(), entries(), counts(), retry(), discard(), purgeOlderThan()
pushpushNow(), notifyOnline(), status(), onStatusChange(), onRejected()
ticksnotify(), flush()
draftsThe draft store behind useDraftField: begin, set, end, endAll
cursorsRead or reset stored cursors: get, all, reset
overlay, applierInternals, exposed for advanced use and tests