npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@syncular/client

v0.49.1

Published

Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)

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 action

Each 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 on FileSystemSyncAccessHandle, unlike the Atomics-based opfs VFS).
  • Browsers without OPFS (~pre-2023) are unsupported: openPersistentWasmDatabase fails loud instead of degrading.
  • OPFS is reload-persistent but begins as best-effort origin storage. The root package exports checkBrowserStoragePersistence() and requestBrowserStoragePersistence() for the page-level persistence policy.
  • Never IndexedDB. There is no wa-sqlite/absurd-sql style fallback and none is planned.
  • openPersistentWasmDatabase refuses 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, or node:sqlite on native runtimes), so there is no second persistence surface and no second eviction policy to keep coherent. Close the app, reopen it: fetchBlob serves the cached body with no network.
  • SQLite handles multi-MB images fine. A page-cached BLOB read 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_ref value 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). Absent blobCacheMaxBytes ⇒ 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:packages

RPC 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:browser

test/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.