@syncular/client
v0.49.1
Published
Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)
Maintainers
Readme
@syncular/client
The TypeScript client protocol core (SPEC.md §§3–8, client side) plus its browser platform bindings.
The normal SyncClient also runs in a CLI or background service.
Use openSqliteDatabase(path) from @syncular/client/sqlite for a persistent
local replica on Node or Bun. See the
server-side sync client guide.
SyncRemoteClient is the database-less server client. It sends ordinary
push-only commits through /sync and can call registered typed queries,
server-authoritative commands, and live query watches through the remote
operation transport. See remote server operations.
Its schema and sync transport are optional for query-only or command-only
processes.
Ordinary commits use wire version 1 until the caller supplies an acquired
partition logEpoch. Set logEpoch after a restore rotation requires epoch
validation.
Applied and cached acknowledgements drain the send queue while protected local
intent stays visible until the same row's server image arrives at that commit
sequence or later. Empty pulls and restart preserve it; later edits stack above
it. This behavior is automatic, independent of retainFailedCommits. The core
requests a following pull without relying on an origin realtime notification.
See optimistic writes.
Client-local FTS5 projections
Generated schemas may attach ftsIndexes to a synced table. The
client materializes each as a contentful local FTS5 table with a private stable
source identity and insert/update/delete triggers. Existing visible rows are
bulk-indexed on first creation; schema reset recreates the projection. The FTS
table is a read-only application query surface: it is never synced, subscribed,
or mutated. A missing FTS5 build fails schema setup explicitly—Syncular does
not substitute an unbounded LIKE scan. Indexed fields must be non-encrypted
strings.
Browser modes — there are exactly two
Persistent worker mode is THE mode. The whole client core — SyncClient, the fetch/WebSocket
transports, and SQLite on the opfs-sahpool VFS — runs inside a Web
Worker. The UI thread talks to it through a thin postMessage RPC:
// worker.ts — the worker entry your bundler emits as its own script
import { startSyncWorker } from '@syncular/client/worker';
startSyncWorker();// main thread
import { createSyncClientHandle } from '@syncular/client';
const handle = await createSyncClientHandle({
worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }),
schema,
database: { mode: 'persistent', name: 'app' }, // OPFS, survives reloads while the origin remains stored
endpoints: {
syncUrl: '/sync',
segmentsUrl: '/segments',
realtimeUrl: 'wss://example.com/realtime?clientId={clientId}',
},
});
if (handle.role === 'follower') {
// Another tab owns the core for this origin. The default multi-tab mode
// transparently proxies this handle to that leader.
}
await handle.subscribe({ id: 'todos', table: 'todos', scopes: { list_id: ['l1'] } });
await handle.syncUntilIdle();
const rows = await handle.query('SELECT * FROM todos');The handle exposes the same logical API as SyncClient (subscribe /
mutate / sync / query / conflicts / …), every method a promise. It
acquires the Web Locks leader lock before spawning the worker — one
core per origin. Wake-ups are handled inside the worker (autoSync,
SPEC §8.4); the supported page-level realtime supervisor owns reconnect and
resume policy. The main thread gets onSyncNeeded / onConflict / onSynced
events for rendering.
A direct SyncClient used by a long-running service exposes
onSyncNeeded() and onSyncIntent(). Install the shared single-flight loop
instead of maintaining host timers:
const scheduler = installSyncScheduler(client, { onError: reportSyncError });
// During shutdown:
scheduler.stop();Creation-time window helpers produce immutable UTC month scope values.
creationTimeBucket(createdAtMs, 'month') returns YYYY-MM, and
last(3, 'month') returns the current and preceding two buckets oldest first.
OPFS is best effort until the browser grants origin persistence. The page owns
that decision because StorageManager.persist() is a Window API and should be
requested from a user action:
import {
checkBrowserStoragePersistence,
requestBrowserStoragePersistence,
} from '@syncular/client';
const current = await checkBrowserStoragePersistence();
const requested = await requestBrowserStoragePersistence(); // user actionEach call returns persistent or a structured best-effort reason. Keep the
client usable after denial, display the state, and warn whenever best-effort
storage contains pending outbox commits. Origin eviction removes OPFS and its
outbox together; persistence does not prevent a user from clearing site data.
Ephemeral in-memory mode is EXPLICIT. openWasmDatabase() returns an
in-memory sqlite-wasm database for tests, demos and SSR. Nothing
persists, on purpose, and that is the only main-thread mode.
Supported realtime lifecycle
Register subscriptions, then install the cross-host supervisor:
import {
browserConnectivitySignal,
documentLifecycleSignal,
installRealtimeSupervisor,
} from '@syncular/client';
await handle.subscribe({ id: 'todos', table: 'todos', scopes });
installRealtimeSupervisor(handle, {
connectivity: browserConnectivitySignal(),
lifecycle: documentLifecycleSignal(),
// Encrypted/locked apps also pass their active/preflight signal.
protection,
});It owns one connection attempt, retries initial failure and later socket loss
with bounded exponential jitter, suspends while offline/background/protected,
runs syncUntilIdle() before reporting connected, and cancels before
close().
Multi-tab handles share ONE leader socket across every tab, so pass
sharedTransport: true (the templates do): backgrounding this tab then
leaves the shared socket alone — a sibling tab may still be visible — while
offline and protection continue to suspend. Each client accepts exactly one
supervisor for its lifetime; a second installRealtimeSupervisor call
throws. realtimeSupervisorSnapshot() and
subscribeRealtimeSupervisor() expose only phase, attempt, and the bounded
library delay for UI/diagnostics—never raw transport prose or identities.
The lower-level connectRealtime() is idempotent while connected and
single-flight while connecting; disconnect invalidates an in-flight result so
it cannot install a stale socket. It remains available for custom host loops.
HTTP rounds still work with no socket, but do not imply continuous convergence:
a host trigger must actually run them. See the complete
realtime lifecycle guide.
Multi-tab followers
By default, every tab of the same origin shares ONE core: one sync loop, one WebSocket, one OPFS database, N tabs.
const handle = await createSyncClientHandle({
worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }),
schema, database: { mode: 'persistent', name: 'app' }, endpoints,
onRoleChange: (role) => console.log('now', role), // 'follower' → 'leader'
});
// Compatibility: handle.role is 'leader' or 'follower'.
// Detailed state: handle.leadership / handle.leadershipSnapshot().
handle.onLeadershipChange((state) => renderConnectionState(state));Topology. The tab that wins the Web Locks election is the leader:
it holds the lock, spawns the worker, and runs the core (the single-tab
path, unchanged — the lock IS the exactly-one-core invariant, and a worker
is never spawned without it). Every other tab is a follower: it opens
a BroadcastChannel to the leader and proxies the whole logical API over
it (req/res), while the leader fans its worker events — exact revisioned
change batches plus presence / conflict / sync-needed / synced / upgrading —
out to all followers (event). Queries forward to the leader's one DB; rows
(including bytes columns) ride back through structured clone.
Promotion. When the leader tab closes, its lock releases. Followers are
already blocked on lock.acquire; Web Locks grants it to exactly one, and
that tab promotes in place — spawns the worker over the same OPFS
database (which persisted; the server is the source of truth, so nothing is
replayed beyond the outbox the core already holds), re-announces on a new
epoch, and flips its role to 'leader'. Remaining followers rebind
to the new leader on its announce. The handle object is kept across the
transition, so a React SyncProvider holds a stable reference.
Epoch (generation token). Each leader announces a monotonically
increasing epoch. Followers stamp requests with the epoch they last heard;
a leader ignores stale-epoch requests, and a follower discards any
res/event from an epoch other than its current one — so a late reply
from a tab that has since died can never be mistaken for a live answer.
Calls made during the handover gap are queued with a deadline and
flushed to the new leader on its announce; past the deadline they fail
loudly with client.follower_timeout (never a silent hang), and an
overflowing queue rejects rather than growing unbounded.
Leader liveness. Followers probe the leader; the leader runs no liveness
timer. A bound follower that has heard no announcement for a third of
followerCallTimeoutMs (default 10 s) posts a hello, and the leader answers
every hello with an announcement from its message handler. Browsers throttle
the timers of hidden tabs (Chrome's intensive throttling wakes them about once
a minute) but still dispatch their BroadcastChannel messages, so a leader in
a background tab keeps its followers bound. When a probe stays unanswered for
the rest of followerCallTimeoutMs, handle.leadership becomes
{ state: 'blocked', reason: 'leader-unreachable', code:
'client.follower_timeout', retryable: true } and calls reject immediately.
A leader tab that processes no messages (hung or frozen) therefore blocks its
followers within followerCallTimeoutMs; a closed leader releases its Web
Lock and hands over without waiting for that deadline. A blocked follower keeps
probing at the same interval: an answer rebinds the same handle, and a granted
Web Lock promotes it. An unreachable BroadcastChannel is never treated as
evidence that the lock owner is stale, so it never authorizes a second worker
or database owner.
Forwarded calls have no deadline. The leader's core serializes window
edits and sync rounds, so a follower's setWindow can wait behind a long
bootstrap download, exactly as the same call does in the leader tab. While
the leader answers probes, a forwarded call waits for its result. It rejects
with client.follower_timeout when the link goes blocked, with
client.leader_handover when another leader announces or this tab promotes
before the answer arrives, and with client.worker_failed when the handle
closes. Only calls queued while no leader is bound keep the
followerCallTimeoutMs deadline; when this tab wins the lock itself, its own
core runs them.
Tabs of different builds never serve each other. Every hello, req,
and announce carries MULTI_TAB_PROTOCOL_VERSION and the schema version.
A follower whose leader differs sends it nothing: handle.leadership becomes
{ state: 'blocked', reason: 'leader-incompatible', code:
'client.leader_incompatible', leader: 'older' | 'newer', retryable: true }
and calls reject with client.leader_incompatible. A leader that hears a
hello from a newer tab closes its core and releases the Web Lock; the newer
tab promotes, and the former leader stays blocked with leader: 'newer'
until it reloads. A leader answers a request with another identity with a
client.leader_incompatible error. Tabs running 0.29.1 or older send no
identity and count as older: they never step down, so a newer tab next to
one shows leader: 'older' until that tab reloads or closes. Bump
MULTI_TAB_PROTOCOL_VERSION with every change to a message shape or to a
forwarded method's arguments or result.
A hidden leader keeps leadership. A visible tab does not take leadership
from a hidden leader. Web Locks steal revokes the lease while the leader's
worker still holds the OPFS database and may be mid-commit, so a transfer
needs a cooperative close-and-release handshake, and every focus change
between tabs would restart the worker, reopen the database, and reconnect the
realtime socket. A hidden leader answers its followers from its message
handler, so leadership moves only when the leader tab closes.
Presence semantics — one device, one peer. All tabs share the leader's
single connection, so a device is exactly ONE presence peer collectively:
identity is (actorId, leaderClientId). A follower's setPresence
forwards to the leader's single publisher; there is no per-tab presence
peer. This is the honest model — the wire only ever sees one connection per
device.
The default is a shared replica: same-origin tabs must use the same persistent database name, lock name, and derived channel name. For an embed, preview, or history entry that is intentionally independent, derive the whole ownership tuple from one stable identity:
const preview = await createSyncClientHandle({
worker,
schema,
database: { mode: 'persistent', name: 'medical' },
endpoints,
replica: { mode: 'isolated', id: 'preview-42' },
});This derives a distinct database name and pool directory, Web Lock name, and
BroadcastChannel name together. isolatedReplicaNames() exposes the same
deterministic tuple for diagnostics and host integration. Replica IDs are
stable code-like values (A-Z, a-z, 0-9, dot, underscore, dash).
During Vite development, retain the React client resource only while its
captured generated schema version and published Syncular runtime identity both
match. The
schema-and-runtime-aware Vite guide uses
retainViteSyncClientResource to close the old worker before constructing a
schema-bump or package-upgrade replacement; hot-reloading query code alone does
not migrate the worker-owned database.
Set multiTab: false to opt out. A losing tab then becomes an
isLeader === false handle whose calls reject with client.not_leader. This
does not solve a coordination-partition mismatch by itself: an independent
instance must also use an isolated database and lock identity. Changing only
the channel, lock, or database name is unsafe or ineffective.
Your own core across tabs
createTabCoordinator is the election, promotion, probe, and build-check
machinery under createSyncClientHandle, around a core you start yourself
(for example a Rust core in your own worker):
const tabs = await createTabCoordinator<ViewEvent>({
name: 'app-core',
version: BUILD_NUMBER,
lead: async ({ broadcast, send }) => {
const core = await startCore();
return {
clientId: core.id,
call: (tabId, method, args) => core.handle(tabId, method, args),
tabClosed: (tabId) => core.dropSession(tabId),
close: () => core.stop(),
};
},
onEvent: render,
});
await tabs.call('navigate', ['/campaigns']);Calls reach the core with the calling tab's id; send(tabId, event) targets
one tab and broadcast(event) reaches all of them. Every tab holds a Web Lock
named <name>:tab:<tabId> until it closes, and a core that defines
tabClosed learns when a tab that called it is gone. A promoted leader runs
lead again on a higher epoch, so followers re-send their per-tab state when
state.epoch changes. See
platform specifics.
React availability guard
The worker handle's schema and leadership snapshots feed the same public React boundary as native clients. Guard the application once instead of parsing errors or inspecting generated schema modules:
<SyncProvider
client={clientResource}
renderBoundary={(state, actions) => (
<SyncBlockedScreen state={state} onRetry={actions.retry} />
)}
>
<App />
</SyncProvider>The state is a discriminated union covering startup, migration,
client-upgrade-required, server-behind, incompatible-schema,
leader-unreachable, and leader-incompatible (with leader: 'older' |
'newer'). Recovery changes the same handle/provider back to its
children; a blocked live query has phase === 'blocked', never an indefinite
loading state.
Privacy-safe support diagnostics
Every direct and Worker/multi-tab client exposes the same versioned snapshot:
const snapshot = await client.diagnosticsSnapshot({
expectedSubscriptions: [
{ id: 'membership-security', table: 'facility_memberships' },
{ id: 'scheduler-window', table: 'surgeries' },
],
});
const off = client.onDiagnostics(() => refreshSupportView());expectedSubscriptions contains application intent only—stable PHI-free ids
and generated table names, never scopes. It lets a support screen distinguish
an absent registration from a legitimate zero-row completed bootstrap. The
snapshot also distinguishes reset, revocation, failure, schema floor, lease
stop, pending outbox, offline transport, and storage pressure/unreadability.
queryFailures retains at most 256 failed owned snapshot reads until each query
reads successfully. An entry contains only its stable PHI-free owner id,
generated table names, a stable code, an optional numeric SQLite result code,
and the first failure time. It excludes SQL, parameters, rows, and SQLite error
prose. Worker leaders and followers return identical evidence with their honest
role.
The contract intentionally excludes scope values, rows and clinical row counts, SQL, paths, client/actor/lease ids, auth, keys, mutation bodies, stack traces, and arbitrary prose. It is safe to copy the JSON snapshot into a redacted support ticket as long as the application also keeps subscription ids free of patient/user data. Do not attach database files, console dumps, query results, or app state alongside it. See SPEC §7.6.
Durable commit outcomes
SyncClient and every host bridge expose commitOutcome(id),
commitOutcomes({ limit?, activeOnly? }), and resolveCommitOutcome(input).
Final applied, cached, conflict, and rejected results are journaled in
the same SQLite transaction that drains their outbox commit. Conflict entries
retain the losing operation plus serverVersion/serverRow; active failures
restore after restart and are never removed by retention. Configure the
history cap with limits.outcomeRetentionMaxEntries (default 1,000).
Failed outcomes additionally retain the complete ordered local commit envelope
as outcome.operations, so a domain recovery flow can reconstruct siblings
that rolled back with the terminating operation. It stays in the protected
client database and is never added to the wire protocol, preferences, or
telemetry; successful and historical outcomes may omit it.
By default, rejected optimistic state is removed immediately, even when a validator emits no server row in the pull half. The TypeScript client stores protected, restart-safe before-images beside each pending outbox commit, restores the last confirmed rows (including rejected deletes and atomic siblings), and then replays later pending edits. These before-images are internal rollback state: they are never encoded on the wire or exposed through pending commits, outcome envelopes, diagnostics, preferences, or telemetry.
Use patch(table, rowId, partial, { baseVersion? }) for editor-style partial
updates. The wire carries a sparse operation: the primary key plus the supplied
columns are present, and the durable local operation keeps the same presence
set so conflict and rejection UI knows which columns the user touched. A
full-row mutate upsert marks every column present. mutate also accepts
op: 'patch' with the primary key and supplied columns. A batch can mix sparse
patches, upserts and deletes atomically. Every sparse patch requires a row in
the local replica at call time. An absent row rejects the entire batch with
sync.row_missing before any local write or outbox insert. Omitted encrypted columns require no
key and retain their ciphertext.
Set retainFailedCommits: true to keep the complete failed aggregate visible
and durable after its outbox drains. Incoming server state advances a separate
base. outcome.retainedRows exposes complete intended rows and the latest
server rows and versions. Resolve explicitly with resolved_keep_server, or
create a reviewed replacement and link it through superseded. Security purge
and scope revocation remove whole retained aggregates. If a sparse conflict
loses its server base, the client keeps the sparse operation and conflict
evidence without materializing a local row from the saved before-image.
A retained insert whose unique key belongs to another server primary key remains
journal intent while reads show the server winner. retainedRows[].uniqueConflicts
contains each matching index, its columns, competing rowId, authorized
serverRow and serverVersion. Keep-mine patches that competing ID at its
current version and links the replacement through superseded. Edit can choose
a free unique key. Take-server uses resolved_keep_server. Revocation and local
security purge erase the retained aggregate's protected journal payloads.
Push batching limits
limits carries three per-request push budgets:
maxPushCommitsPerRequest: whole commits per request. Default: unbounded.maxPushOperationsPerRequest: operations across those commits. Default: 500.maxPushRequestBytes: bytes of the complete encoded SSP2 request, including the request header, pull header, subscriptions, framing, and E2EE ciphertext. Default: unbounded.
The client sends whole commits in FIFO order and never splits one. A later
commit that does not fit defers with the complete suffix. A first commit over
the operation or byte budget fails with client.push_request_too_large
(non-retryable) and details { kind, limit, size, clientCommitId? }; the
commit stays queued with its optimistic rows. A request whose fixed frames
alone exceed the byte budget reports the same code without clientCommitId.
Push limits are integers in 1..=4294967295; a JSON null at the native
command boundary is treated as omitted.
Application-authorized local security purge
purgeLocalData({ purgeId, targets }) is the narrow local-storage primitive
for an application that has already validated a server-authoritative device,
membership, or key-revocation directive. It is available on direct clients,
worker handles, the normalized React client, and the Tauri bridge.
const result = await client.purgeLocalData({
purgeId: directive.id,
targets: [
{
table: 'patient_notes',
selectors: { encryption_key_id: [directive.keyVersionId] },
},
],
});The host MUST first quarantine the affected feature and gate/remove any subscription that could download the protected rows again. This method does not authenticate a directive, revoke server authority, delete app-owned files, or remove a key from the OS secure store.
For a race-free bootstrap, construct the client/worker handle with
securityPreflight: true. Before activation, protected reads, writes,
subscriptions, sync/realtime, blobs, and the automatic host loop fail with
client.security_preflight_required; status, local revision, lifecycle, and
the exact local purge remain available.
const client = await createSyncClientHandle({
...config,
securityPreflight: true,
});
await client.purgeLocalData(directive.plan);
await client.activateSecurity({ encryption: acceptedKeyring });Use beginSecurityPreflight() before a live key rotation/revocation. It gates
new calls immediately, disconnects realtime, waits for in-flight core/blob work,
and releases the old keyring before resolving. In multi-tab mode the gate
belongs to the single shared leader replica. Direct clients expose the same
lifecycle with an EncryptionConfig; Worker handles use the portable keyring.
Within one local SQLite transaction the engine deletes exactly the matching
synced rows, lets generated FTS triggers remove their projections, drops every
whole pending commit with a matching operation, restores/replays unrelated
optimistic state, removes unreferenced cached blob bodies, persists the purgeId, and emits
one revisioned change batch. A retry with the same canonical plan returns
alreadyApplied: true; reusing an id with different selectors fails closed.
Only bounded, non-empty, code-like values on plaintext string schema columns
are accepted. There is intentionally no full-table mode. The result exposes
counts only—never row ids or selector values.
Application-authorized projection rebootstrap
rebootstrapLocalData({ rebootstrapId }) is the recovery primitive for a
locally damaged or persistently inconsistent replicated projection. It drops
and recreates only Syncular's server-derived tables, rewinds the existing
subscription registrations, and requests a fresh bootstrap. It preserves the
client id, lease state, pending outbox commits, commit outcomes, subscription
intent, and protected bookkeeping.
const result = await client.rebootstrapLocalData({
rebootstrapId: crypto.randomUUID(),
});The reset, durable idempotency marker, and optimistic outbox replay are one
SQLite transaction. An interruption therefore leaves either the old
projection or the fully reset projection with pending offline work still
visible. Reusing the same id returns alreadyApplied: true together with the
original retainedCommits and resetSubscriptions counts. The core stores
that counts-only receipt atomically with the reset, so an application crash
after the SQLite commit but before its own acknowledgement does not turn the
retry into a misleading zero-impact result. Markers persisted in the legacy
format carry no counts receipt; a retry against one of them keeps returning
the zero-count result. A malformed or unreadable persisted receipt fails
closed with the sanitized sync.local_corrupt client-local code and performs
no reset. No receipt exposes ids, rows, scopes, or clinical values.
Worker, Tauri, and React Native adapters strictly decode the exact result
shape before returning it. Missing or additional fields, a non-boolean
alreadyApplied, or counts that are not non-negative safe integers fail with
client.invalid_host_response; raw bridge values and native error prose must
not be persisted as recovery evidence. decodeLocalDataRebootstrapResult() is
public for custom command hosts that expose the same operation.
This API is not a security erase, sign-out, membership revocation, schema upgrade, or draft deletion mechanism. It fails closed during security preflight and while a schema-floor stop is active. The application must show a preview and explicit confirmation, preserve app-owned drafts/files separately, and reserve the operation for diagnostics/support recovery rather than normal startup.
Validator rejections may include bounded details (fieldPaths, reason,
requiredAction, and explicitly safe references). The details persist with
the rejection. Treat every value as a machine hint: map known values to
localized app UI and never render the diagnostic message directly.
Resolution is explicit and one-way: conflicts can keep the server result or link to a replacement commit, rejections can link to a replacement, and successful history may be dismissed. See SPEC §7.2.1.
The support floor (no fallback ladder)
- Persistence is OPFS via
opfs-sahpool, only. No COOP/COEP headers and no SharedArrayBuffer are required (sahpool is built onFileSystemSyncAccessHandle, unlike the Atomics-basedopfsVFS). - Browsers without OPFS (~pre-2023) are unsupported:
openPersistentWasmDatabasefails loud instead of degrading. - OPFS is reload-persistent but begins as best-effort origin storage. The root
package exports
checkBrowserStoragePersistence()andrequestBrowserStoragePersistence()for the page-level persistence policy. - Never IndexedDB. There is no wa-sqlite/absurd-sql style fallback and none is planned.
openPersistentWasmDatabaserefuses to run on the main thread — not a sahpool limitation, an enforcement of whole-core-in-a-worker.
OPFS ownership and startup recovery
An OPFS SAH pool has exactly one live owner per storage directory. Syncular's
default multi-tab mode prevents ordinary same-origin tabs from opening a second
pool: followers proxy the one leader over BroadcastChannel. A collision can
still happen during rapid hot-module replacement, or in an embedded/test host
that shares OPFS data without sharing the same Web Locks and BroadcastChannel
coordination domain.
Persistent worker startup retries retryable client.storage_busy failures
while retaining the leader lease. It opens the same directory at most seven
times, waiting 50, 100, 200, 400, 800, and 1000 ms between attempts. These delays
total 2550 ms; browser scheduling and storage operations can extend elapsed time.
Other errors fail immediately. Direct openPersistentWasmDatabase calls remain
single attempts.
If ownership remains unavailable, handle creation rejects with ClientSyncError,
code client.storage_busy, and retryable === true, then releases the worker
and leader lease. Close the competing instance or let it finish shutting down,
then create the handle again. Do not delete, rename, or silently replace the database with an
in-memory one; the local database and pending outbox may be perfectly healthy.
Missing or obsolete OPFS APIs instead use the non-retryable
client.storage_unavailable code.
A worker graph that still names a retired Vite optimizer chunk instead uses
the non-retryable client.worker_restart_required code. Restart the dev server
and reload the page; do not clear OPFS. The original bundler message and chunk
URL are deliberately not copied into the public error.
When using @syncular/react, createSyncClientResource() exposes retry() and
passes the same action as the second argument to SyncProvider.renderError.
Applications may use a small bounded backoff for errors whose retryable flag
is true, followed by a visible manual retry. Preserve the resource across HMR
or dispose the previous resource before replacing it so development reloads do
not manufacture a second owner.
Blob attachments (§5.9) — the client storage model
File attachments (blob_ref columns) ride the uploadBlob / fetchBlob API
and are cached locally. Blob bytes live as BLOB columns in the client's own
SQLite database (a _syncular_blobs cache table), not in a separate OPFS
directory or IndexedDB store. This is the pinned decision (SPEC §5.9.7 B1):
- One storage system. The bytes, upload state, and pending-commit references share the client database. SQLite commits each change atomically.
- Survives restarts for free. The client DB already rides OPFS via the
sahpool VFS in the browser (and a plain file under
rusqlite,bun:sqlite, ornode:sqliteon native runtimes), so there is no second persistence surface and no second eviction policy to keep coherent. Close the app, reopen it:fetchBlobserves the cached body with no network. - SQLite handles multi-MB images fine. A page-cached
BLOBread is a memory copy, well within the image/document envelope this targets.
Size cap
Pass blobCacheMaxBytes to cap the on-device cache. When the sum of cached body
sizes exceeds the cap, the client evicts bodies with no visible row or pending
commit by creation timestamp, then blob ID, until back under the cap:
new SyncClient({ /* … */, blobCacheMaxBytes: 256 * 1024 * 1024 }); // 256 MiB- A body referenced by a live row is never evicted. It stays resolvable without a re-download.
- A body needed by a pending upload (not yet pushed) is never evicted. Its bytes are the only copy until the commit drains.
- Evicting an unreferenced body only costs a future re-download, never correctness:
any surviving
blob_refvalue re-enables the fetch (§5.9.7 B3). If every over-cap body is referenced or pinned, the cache stays over the cap (correctness beats the cap). AbsentblobCacheMaxBytes⇒ retain until storage pressure (the default).
Very large media — the escape hatch
SQLite is not the store for gigabyte video: a single BLOB must fit the
client's memory and the SQLite row-size envelope. For very large media, run the
server with presigned downloads (blobSignedUrls) and hand the presigned URL
straight to a media element instead of pulling bytes through the cache — the
image-app default (BLOB cache) and the large-media path (presigned
URL, no byte cache) coexist per attachment.
Node and Bun SQLite backend (./sqlite)
CLIs, background workers, Electron main processes, and services can use one runtime-selected import:
import { openSqliteDatabase } from '@syncular/client/sqlite';
import { SyncClient } from '@syncular/client';
const database = openSqliteDatabase('app.db'); // or ':memory:' (default)
const client = new SyncClient({ database, schema, /* … */ });The export selects bun:sqlite on Bun and the built-in node:sqlite module on
Node 22.13 or newer. No SQLite package or native addon is required. Both
adapters support synchronous exec, query, nested transactions, boolean
bindings, null, Uint8Array BLOB values, and §5.3 SQLite-image attachment.
Persistent Bun and Node databases use SQLite WAL journaling with
synchronous=FULL. Each application or sync commit retains its own durable
transaction. SQLite keeps in-memory databases on its memory journal. Use a
local filesystem path; WAL uses adjacent -wal and -shm files. Close every
connection before copying the database file, or use SQLite's backup facilities
while it is open. Copying only the main file while writers are active can omit
committed WAL contents.
Runtime-specific imports remain available:
import { openBunDatabase } from '@syncular/client/bun';
import { openNodeDatabase } from '@syncular/client/node';The source and packed-package runtime contracts run under actual Node and Bun:
bun run verify:runtimes
bun run build:packages && bun run verify:packagesRPC protocol (6 message types)
init, call, ready, result, error, event — every API method
multiplexes over call (typed end-to-end from the single WorkerApi
shape in worker-protocol.ts); event carries sync-needed,
change, conflict and synced. Query-result blobs transfer (not copy) when
they own their buffer.
Package layout
| Entry | Contents |
|---|---|
| . | protocol core, transports, handle + RPC protocol (browser-safe, no SQLite) |
| ./worker | startSyncWorker — worker-side bootstrap (pulls sqlite-wasm) |
| ./wasm | sqlite-wasm bindings: openPersistentWasmDatabase, openWasmDatabase |
| ./sqlite | Runtime-selected SQLite: openSqliteDatabase on Node or Bun |
| ./bun | Explicit bun:sqlite binding: openBunDatabase |
| ./node | Explicit built-in node:sqlite binding: openNodeDatabase |
Tests drive the real worker entry in a bun Worker with bun:sqlite
injected through the bootstrap's database-factory override
(test/worker-rpc.test.ts). The separate Chromium gate exercises actual
sqlite-wasm, OPFS SAH pools and FTS through the production worker and HTTP paths:
cd packages/web-client
bunx --no-install playwright install chromium
cd ../..
bun run test:browsertest/opfs-bootstrap.browser.test.ts covers five interruption boundaries:
- Partial segment download: hold the asynchronous transfer, then terminate the worker.
- Before image import: inject an exception before applying the verified image.
- Mid-import: terminate the worker after an actual OPFS database page write during
INSERT SELECT. A commit notification must not precede termination. - After import: inject an exception after the transaction commits, before
SUB_END. - After checkpoint: complete the round before terminating the worker.
Each case reloads the page using the same replica identity and requires successful startup in the same browser session. It checks SQLite and FTS integrity, search results, exact rows, persisted client identity, and subscription checkpoints. Incomplete sections must download again; completed sections must not. A held synchronous test request after a physical page write keeps the mid-import crash boundary stable until worker termination. Two additional ownership cases verify recovery when a competing owner closes during retry and bounded failure while that owner remains live, including pending outbox preservation.
Each case owns a temporary browser profile and a loopback server. A small SQLite
page cache forces dirty pages to disk before COMMIT. Tests use no timing sleeps,
COOP/COEP requirement, existing profiles or application replicas. Failed profiles
and recovery-test.json remain at the path printed by the test; CI retains them
as an artifact. Successful profiles are removed. bun run check keeps its
Bun-only test lane; CI runs the browser gate separately.
openPersistentWasmDatabase corrects the SAH-pool VFS's reserved-lock callback
before the first SQL statement. sqlite-wasm 3.53 reports a reserved lock
unconditionally, suppressing SQLite's hot-journal recovery after an interrupted
write. The pool already excludes other owners. Syncular reports no competing
writer through SQLite's I/O method binding, matching the other OPFS VFS, and
retains that callback across database closes and pool opens. The correction ships
in the client package and preserves the existing database format, DELETE journal
mode and FULL synchronous mode.
On 2026-09-12, Chromium 153 with sqlite-wasm 3.53.0 failed the mid-import case
without this correction and passed all five cases with it. The fixture serves
the unmodified dependency. Bounded worker startup retries handle transient
client.storage_busy while the browser releases old handles. This correction enables
rollback from an intact hot journal; it does not repair existing corruption
whose recovery journal has already been lost or overwritten.
Live sync progress
onProgress observes work during a sync round. It immediately delivers the latest
snapshot when one exists and returns an unsubscribe function. progressSnapshot()
reads the cached snapshot synchronously, including on worker handles. Worker
leaders and followers forward events while download and import are running.
const stop = client.onProgress((progress) => {
if (progress.phase === 'download') {
renderDownload(progress.bytesReceived, progress.bytesTotal);
} else if (progress.phase === 'import') {
renderImport(progress.rowsProcessed, progress.rowsTotal);
}
if (progress.state === 'failed') showError(progress.errorCode);
});
// Stop listening when the view is disposed. Sync continues.
stop();Each snapshot identifies the round with attempt, and the current payload with
subscriptionId, table, and optional segmentId. Counters reset for each
payload; syncUntilIdle starts a new attempt for each round. phase is request,
download, or import; state is running, complete, or failed. Unknown
totals remain absent. A new attempt clears prior errors and counters.
HTTP downloads report decoded body bytes at 64 KiB intervals or larger network
chunks. Custom segment downloaders receive request.onProgress; signed-URL
implementations receive an optional second callback argument. Call it with the
cumulative received byte count. Buffered custom transports report a final count
through the core, without intermediate download updates.
Import reports every 1,024 rows and its final count. rowsProcessed includes work
inside the current transaction; a failed import can roll those rows back. SQLite
images commit in chunks of at most 1,024 rows and yield after each commit.
Earlier committed chunks survive a later interruption. complete follows checkpoint persistence
and optimistic-state reconciliation for the round. More bootstrap pages can
remain; use subscription coverage to determine whether a query is complete.
Listener exceptions do not fail sync. Listeners should hand UI work off and avoid
reentering database commands during an import.
Snapshot API migration
This source-breaking revision uses methods for application reads across the
direct client, worker leaders and followers, Tauri, and React Native. Replace
client.conflicts, client.rejections, and client.securityLifecycle on the
direct client with method calls. Replace schemaFloor, leaseState,
upgrading, and syncNeeded getters or bridge methods with fields from one
statusSnapshot() call:
const status = await client.statusSnapshot();
if (status.schemaFloor) showUpgradeRequired(status.schemaFloor);
const conflicts = await client.conflicts();
const outcome = await client.commitOutcome(commitId);The direct client returns snapshots synchronously. Worker and native bridges
return promises; await works with both. querySnapshot returns rows, coverage,
and revision from one read. diagnosticsSnapshot, commitOutcome,
commitOutcomes, and resolveCommitOutcome retain their existing arguments.
The shared ClientSnapshotMethods and PromiseMethods types describe these
contracts. Key-bearing security activation stays on each concrete host type.
React uses the supplied client directly; useSyncClient() preserves its
identity. Remove imports of normalizeClient and the
@syncular/client/realtime-supervisor-observation forwarding utility. Pass the
client to SyncProvider and use realtimeSupervisorSnapshot(client) to inspect
an attached supervisor. Custom React clients must implement the snapshot
methods and method-form collection reads. See the React migration
for the onEnqueued callback rename.
Outbox read costs
Request encoding pins the pending count and highest local sequence before its
first asynchronous step. It reads keyset pages of 32 raw records, decodes only
the consumed prefix, and stops at the first whole commit that exceeds the
remaining operation budget. Mutations appended during encoding enter the next
request. Status and diagnostics use COUNT(*) without parsing pending bodies.
Optimistic replay still reads the remaining outbox after each response. The
100/1,000/10,000-commit workload and measured limits are recorded in
the reliability RFC.
Reads during bootstrap and eviction
Reactive queries read cached snapshots while window registration runs. Coverage continues to distinguish complete, pending, and missing windows; registration errors remain observable. Retained owners reuse acknowledged units immediately.
Both segment formats yield between committed import chunks automatically. Window eviction deletes at most 1,024 unpinned rows per chunk and resumes from a durable pending record after interruption. Security purges remain atomic. See windowing and bootstrap for coverage and recovery.
Custom database adapters implementing withSqliteImage must return a promise and
await their callback before detaching the image. The callback can await between
committed import chunks; no write transaction spans that await.
Preflight authority reads
Import defineAuthorityReads from @syncular/client/authority. Pass
authorityReads: defineAuthorityReads([{ table, columns, scopes }]) at
creation to enable the zero-argument authoritySnapshot(). It reads approved
plain authority columns and accepted bases with revision and persisted scope
coverage from one SQLite snapshot, including during security preflight.
Unaccepted local intent remains separate. The read changes no keys, transport,
subscriptions or lifecycle. See the authority contract
and native configuration.
Workers must opt in in their entry script:
import { defineAuthorityReads } from '@syncular/client/authority';
import { startSyncWorker } from '@syncular/client/worker';
startSyncWorker({ createAuthorityReads: defineAuthorityReads });Pass the policy to createSyncClientHandle({ authorityReads, ...config }) on
the main thread. Declarations without the injected worker factory fail with
client.authority_read_forbidden before opening storage. Ordinary workers
keep startSyncWorker() and bundle no authority reader. No dynamic import
changes the declared capability.
