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:
from | Starts at | Use it for |
|---|---|---|
'cursor' (default) | The stored cursor | Normal incremental pulls |
'window' | 1000 below the cursor | Pulls triggered by a change notification (see Ticks) |
0 | The beginning | A 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.tablesinstead), - the row doesn't exist locally,
- a column isn't in the schema,
changesis 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:
| Status | Meaning |
|---|---|
pending | Recorded, not sent yet |
sending | In a batch waiting for an answer |
applied | The server applied it |
noop | The server already had that value |
rejected | The 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
| Option | Default | Meaning |
|---|---|---|
db | required | The open Database |
transport | required | Your SyncTransport |
deviceId | required | Sent with every push; use a stable id per installation |
debounceMs | 2000 | Delay between the last record and the push |
maxChangesPerBatch | 500 | Changes per push batch (500 is also the maximum) |
pullWindow | 1000 | How far from: 'window' rewinds |
pageLimit | – | Page size sent to pullRows. Left out, the server picks |
tickWindowMs | 1500 | How long ticks are collected |
retentionDays | 30 | Settled outbox history to keep |
isTerminalError | every error retries | Which 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:
| Property | Use |
|---|---|
pull | pull(), registerScope() |
outbox | record(), entries(), counts(), retry(), discard(), purgeOlderThan() |
push | pushNow(), notifyOnline(), status(), onStatusChange(), onRejected() |
ticks | notify(), flush() |
drafts | The draft store behind useDraftField: begin, set, end, endAll |
cursors | Read or reset stored cursors: get, all, reset |
overlay, applier | Internals, exposed for advanced use and tests |