@baukit/sync-client
@baukit/sync-client holds client-side mechanics that offline-capable products rebuild around a
sync loop: scheduling, transport, status, push ordering, and hybrid logical timestamps. It does
not define a sync protocol. Endpoint paths, payloads, conflict rules, and the sync engine itself
stay product-owned, as
the offline readiness contract says they
must.
The root entry has no runtime dependencies and no React. Timers, foreground state, connectivity,
and fetch all arrive by injection, so the same code runs under Node, a browser, and React Native.
The optional @baukit/sync-client/expo entry imports react-native and expo-network; neither is
loaded by the root entry. The @baukit/sync-client/browser entry reads browser globals only when
createBrowserSyncEnvironment is called, so importing it under Node is safe.
Hybrid logical clock
Import the clock from the root package or @baukit/sync-client/hlc. It uses the same integer
encoding as baukit_sync::hlc, so Rust and TypeScript can compare stored timestamps directly.
import { HybridLogicalClock } from '@baukit/sync-client/hlc';
const clock = await HybridLogicalClock.open('device-a', metadataStore, Date.now);
const localTimestamp = await clock.now();
const afterRemote = await clock.observe(remoteTimestamp);
const state = clock.snapshot();
The encoding is wallTimeMs * 1000 + counter + 1. The added one reserves zero as invalid. Use
encodeHybridLogicalTimestamp, decodeHybridLogicalTimestamp, and
compareHybridLogicalTimestamps when no stateful clock is needed. Comparison returns null if
either input is invalid.
JavaScript can represent integers exactly only through Number.MAX_SAFE_INTEGER,
9_007_199_254_740_991. The last valid HLC value decodes to wall time 9_007_199_254_740 and
counter 990. Calls beyond that point reject with HybridLogicalClockError code
exceeds_safe_integer and leave committed state unchanged. Within the normal range, counter 1,000
moves the wall component forward by one millisecond and resets the counter to zero.
The device ID and physical clock are caller inputs. The module does not generate device IDs or
choose a storage system. Pass no store for an in-memory clock, or implement HlcStorage for product
persistence. A stored state is restored only when its device ID matches and its components form a
valid encoded timestamp. Missing, corrupt, and foreign state reset to zero. Storage read and write
failures reject unchanged.
When storage is present, now and observe run in call order. A later call waits for the prior
write to finish. Failed writes do not update the snapshot and do not stop the queue. snapshot
therefore reports the last state committed to storage.
Clock migration
Redemut can replace its local HLC import with @baukit/sync-client/hlc. The existing
open(deviceId, storage, physicalClock) call and hlc-state record remain compatible. No stored
timestamp or state rewrite is needed. Redemut keeps its IndexedDB and SQLite adapters, device ID
creation, merge rules, server compare-and-swap, and cursor protocol.
Scheduler
SyncScheduler takes one opaque run() callback and a SyncSchedulerEnvironment. It guarantees
a single active run: a trigger arriving mid-run joins that run instead of starting a second one.
const scheduler = new SyncScheduler(() => engine.run(), environment, { intervalMs: 60_000 });
scheduler.start();
trigger() starts a run or joins the active one. requestFollowUp() is what a local write calls:
if a run is already active it queues exactly one replay after that run settles, so a write made
while the engine was mid-push is never left unsent. Repeated follow-up requests still collapse
into one replay. stop() drops the queued replay along with the interval and subscriptions.
The environment reports foreground state and connectivity. The scheduler runs on start, when the app returns to the foreground, when connectivity returns, and on the interval. It installs no interval while backgrounded and ignores connectivity events there.
A failing run reaches onError and does not stop scheduling. Without retry, the next attempt
waits for the next trigger.
Retry and backoff
Pass retry to retry a failed run after a delay. The product decides which failures are worth a
retry and how long to wait, because only it knows its error types; the scheduler owns the attempt
count, the wait, and waking it early. fullJitterBackoffMs from @baukit/api-runtime/backoff is
the Baukit delay policy. The root entry of this package still imports nothing, so the product
passes the policy in:
import { fullJitterBackoffMs } from '@baukit/api-runtime/backoff';
import { SyncRateLimitError, SyncScheduler, SyncTransportError } from '@baukit/sync-client';
const scheduler = new SyncScheduler(() => engine.syncOnce(), environment, {
retry: {
maxRetries: 4,
delayMs: (error, retryIndex) => {
if (error instanceof SyncRateLimitError) return Math.max(0, Date.parse(error.retryAt) - Date.now());
if (error instanceof SyncTransportError && !error.retryable) return null;
return fullJitterBackoffMs(retryIndex, { baseDelayMs: 500, maxDelayMs: 30_000 });
},
onRetryScheduled: ({ delayMs }) => status.showRetryAt(Date.now() + delayMs),
},
});
After a failed run the scheduler asks delayMs(error, retryIndex). null means no retry. A number
is the wait in milliseconds, and onRetryScheduled hears about it before the wait starts. The
retry index counts from 0 and restarts after a success. After maxRetries retries the scheduler
waits for the next trigger. A delay that is negative or not finite, or a delayMs that throws,
reaches onError and ends the retries. A maxRetries that is not a non-negative integer throws a
RangeError from the constructor.
The wait is a one-shot timer built from the environment's setInterval, so no environment
changes. Any trigger during the wait ends it at once and restarts the retry index at 0: a manual
trigger(), the interval, returning to the foreground, or connectivity returning while in the
foreground. A follow-up request during the wait does not end it, because the retry will send the
same writes. stop() ends the wait without another run, and a wait that ends while the app is
backgrounded runs nothing until the app returns.
If the engine waits inside its own retry delay instead, pass onRecoverySignal to the scheduler
and wake that delay there. The scheduler calls it with active before a foreground-triggered run and with
online before an online-triggered run. Online recovery is reported even while the scheduler is
backgrounded, although the scheduler still waits for foreground before starting another run.
Retry wake-up belongs in this scheduler option, not in a decorated environment, because the
environment does not know whether an engine has a retry delay.
const scheduler = new SyncScheduler(() => engine.retryNow(), environment, {
onRecoverySignal: () => engine.wakeRetryDelay(),
});
Browser environment
Browser products can import an environment that maps document visibility, online events, and intervals to the scheduler contract:
import { SyncScheduler } from '@baukit/sync-client';
import { createBrowserSyncEnvironment } from '@baukit/sync-client/browser';
const scheduler = new SyncScheduler(() => engine.retryNow(), createBrowserSyncEnvironment(), {
onRecoverySignal: () => engine.wakeRetryDelay(),
});
isActive is true only while document.visibilityState is visible. A visibility change reports
the new active state, and an online event reports a connectivity wake signal. Each returned
cleanup function may be called more than once and removes its listener once.
Tests and non-browser hosts can inject { document, window, timers }. Calling the factory without
injections outside a browser throws a specific missing-document or missing-window error. Importing
the entry never reads either global. The browser entry has no React, Dexie, or sync-engine import.
Expo environment
Expo products can use the supplied environment instead of repeating AppState and network wiring:
import { SyncScheduler } from '@baukit/sync-client';
import { createExpoSyncEnvironment } from '@baukit/sync-client/expo';
const scheduler = new SyncScheduler(() => engine.run(), createExpoSyncEnvironment());
The environment treats only AppState active as foreground. On native platforms it reads the
initial state from expo-network and triggers only when connectivity changes from unusable to
usable. Expo web uses the browser online event. Tests can pass { timers } to replace
setInterval and clearInterval. react-native and expo-network are optional peers because
non-Expo consumers never import this subpath.
Transport
The fetch-backed SyncTransport constructor wraps an injected fetch and auth header provider.
It classifies failures as follows:
SyncAuthErrorfor 401, never retryableSyncPartitionMismatchErrorwhen the body carries the configured partition-mismatch codeSyncRateLimitErrorfor 429, always retryable and carryingretryAtplus the rawretryAfterheaderSyncNetworkErrorwhenfetchor response-body reading fails, always retryableSyncServerErrorfor other HTTP failures, retryable for 408 and 5xxSyncPayloadCompatibilityErrorwhen a successful response is not valid JSON, never retryable
Retry-After accepts integer delta seconds and HTTP dates. Missing, invalid, negative, and past
values use now + retryAfterFallbackMs. The default fallback is 60 seconds. Tests and products
with another policy can inject now and retryAfterFallbackMs in the fetch-backed constructor.
Every request has a deadline, requestTimeoutMs, 30 seconds by default (DEFAULT_REQUEST_TIMEOUT_MS).
It covers the fetch and the body read. When it passes, the transport aborts the request's signal
and rejects with a retryable SyncNetworkError, so the scheduler's next run opens a new
connection instead of waiting on a pooled one whose packets get dropped. Both constructors take
the setting. A prebuilt request function receives the deadline signal and should pass it on to
its fetch. A caller's own signal still aborts the request.
Auth headers resolve per attempt, so a token refreshed between retries is picked up. The
partition header is sent only when the caller passes a partitionId, and both its name and the
mismatch code are configurable. The transport never names an endpoint: callers pass the path and
the response type.
A product API client often already owns the base URL, token refresh, authentication recovery, response decoding, and its error policy. Pass its decoded request function instead of duplicating that work in the transport:
const transport = new SyncTransport({
request: apiClient.request.bind(apiClient),
partitionHeader: 'X-Product-Profile-Id',
});
await transport.request('/api/v1/sync/push', {
method: 'POST',
body: { changes },
partitionId: profileId,
});
This mode builds the relative path, query string, JSON body, and partition header, then delegates. The request function owns decoding and errors, which keeps the product API client's existing auth recovery intact. Use the fetch-backed constructor when the product wants package-owned response classification.
Status store
SyncStatusStore is the observable sync status, with subscribe/getSnapshot and no state
library. It encodes rules the offline readiness contract requires and products keep getting
wrong:
- an actionable rejection outranks a transport failure, so a failed run over existing conflicts
still reads
attention; - unsent work outranks a bare error, so a failed run with pending changes reads
pending, noterror; - the first run is the initial pull; later runs are settled activity;
- a failure updates
lastAttemptAtwithout changinglastSuccessAtor clearingpendingCount.
Call setSyncing(attemptAt) when an attempt starts. setIdle(successAt) records a successful
attempt and clears work only after the product's success predicate passes. setFailure accepts
machine-readable metadata with one of these kinds: auth, partition_mismatch, rate_limited,
network, server, payload_compatibility, or local_apply. Product copy remains in the
separate error field.
Rate-limit metadata contains retryAt. Other retryable failures can pass retryAt in the
SyncFailureUpdate argument or call setRetrying(retryAt). Snapshots expose both retrying and
retryAt, so a screen can distinguish scheduled retries and rate limits from an offline state.
refreshRevision increments whenever a run delivers a new authoritative snapshot.
deriveLocalStoreReadiness compares it against what a consumer has actually rendered, which is
how a cold start avoids flashing an empty state over data that is still arriving.
deriveInitialSyncState maps a snapshot onto unknown, syncing, offline-cached,
sync-error-cached, or settled. Only a first-run network failure maps to offline-cached.
The store is generic over its attention item and defaults to the existing
{ entityType, entityId } shape. This keeps one store API while allowing a product to retain the
rejection details its UI needs:
interface ProductAttention {
objectEntityType: string;
objectEntityId: string;
rejections: readonly Rejection[];
}
const status = new SyncStatusStore<ProductAttention>();
status.setAttention(items, pendingCount);
Cold start
Persist both attempt and success timestamps, then hydrate with an object:
status.hydrate({
lastAttemptAt: persisted.lastAttemptAt,
lastSuccessAt: persisted.lastSuccessAt,
attention: persisted.attention,
pendingCount: persisted.pendingCount,
});
Push batch ranking
rankPushBatch orders one push batch so a parent is never sent after its children.
const rank = dependencyRankByOrder(['container', 'group', 'leaf']);
const batch = rankPushBatch(pending, {
rank,
batchSize: 200,
isHeldBack: (change) => hasUnsentChildren(change),
});
Changes are grouped by entityType:entityId. Each group keeps the position of its first
occurrence, so foreign-key order survives coalescing, and carries the newest change so the
request sends current data. coveredChangeIds lists every queued change the group settles,
because server outcomes are keyed by entity rather than by outbox row: one outcome clears all of
them.
isHeldBack is the other half of the rule. A parent whose required children are still unsent is
dropped from the batch rather than reordered, and retried on a later run once those children have
been accepted. Sending it now would show the server a complete parent while a required child is
missing. Held-back entities do not consume batch-size slots.
Entity names, the dependency order, and the hold-back predicate are all product inputs. Baukit knows only that a rank exists.
Payload validators
Call validatePushOutcomeCoverage before acknowledging any submitted change. Pass one key reader
for submitted entities and one for outcomes, then combine all accepted and rejected outcomes in
the second argument. The function throws SyncPayloadCompatibilityError if any submitted entity
has no outcome.
Call validatePullPage(currentCursor, page, compare) before following hasMore. Cursors are
opaque. The injected comparator defines their order. A regressing cursor, a non-finite comparison,
or hasMore without cursor progress throws SyncPayloadCompatibilityError.
commitCursorAfterLocalTransaction runs the supplied local transaction first. It invokes
commitCursor only after that promise resolves, and wraps local failures in SyncLocalApplyError.
This ordering prevents a failed local apply from skipping remote changes on the next pull.
Conformance harness
@baukit/sync-client/conformance exports createSyncConformanceTests. It returns plain async test
cases and has no Vitest or Jest dependency. Register the returned cases with the runner already in
the product:
import { createSyncConformanceTests } from '@baukit/sync-client/conformance';
describe('product sync contract', () => {
for (const testCase of createSyncConformanceTests(productAdapter)) {
it(testCase.name, testCase.run);
}
});
The adapter supplies two isolated clients and one fake server per case. It also supplies callbacks for outbox enqueue, listing, and stable pending IDs; atomic submitted-batch settlement; atomic pull application and cursor storage; pending-state reporting; wire encoding and decoding; and fake-server push, pull, seeding, snapshots, and fault injection. Canonical fixture changes contain opaque entity names, values, logical times, dependencies, and tombstones. The adapter maps them to the product's schema and payloads.
The cases cover replay, transaction rollback, paged cursor progress, stalled and regressing
cursors, complete push outcomes, late accepted and rejected responses, stale pulls over pending
writes, pending state after failures, dependency-safe push order, and two-client convergence with
a tombstone. When fullResync is present, five more cases cover monotonic purge horizons, cursor
zero, parent and child cleanup, pull-only rows, pending edits, rejection records, reset rollback,
reset deferral, and the repeated-stale stop condition. wire.decodePush must call
validatePushOutcomeCoverage before returning acknowledged rows. It returns decoded
rejectedRows when a rejection contains an authoritative server row.
local.applySubmittedBatchOutcome receives the exact submitted rows, the raw push response,
the decoded acknowledged rows and rejections, and any decoded rejected server rows. In one storage
transaction it must settle only pending IDs covered by that submission, record actionable
rejections, apply safe rejected rows, and perform any accepted-revision stamping. A newer pending
row for the same entity blocks an older response from replacing its local payload. Revision
stamping is product policy. The harness does not require last-writer-wins or a particular revision
type, but a stamp from an older submission must not replace the payload of a newer local write.
The pull callback must keep a pending local row when an older or equal remote revision arrives, while committing the page cursor in the same transaction. Its injected failure must roll back both the staged row and cursor.
Tombstone-horizon conformance
Adapters for products with finite tombstone retention set the optional fullResync field. Products
without it receive the original case set and need no adapter change.
zeroCursor is the product's explicit full-rebuild cursor. decodeStaleCursor maps the product's
wire error onto { code: 'resync_required', horizon }. The product response must keep the code and
horizon field stable. For the HTTP 409 envelope, the source value is
error.details.horizonRevision.
isResetSafe is the production policy hook. setResetSafe lets the isolated test client enter a
safe or unsafe state. reset clears server-backed rows and writes zeroCursor in one transaction,
while keeping pending mutations, rejection records, and their repairable rows. It must honor
failAfterRows inside that transaction so the rollback case can inspect the unchanged database.
readResetCount lets the suite prove that an immediate second stale response does not start a
loop.
The fake-server callbacks stay test-only. purgeTombstones removes the requested fixture rows and
returns the owner's current horizon. rejectNextZeroCursor injects a protocol violation for the
stop-condition case. Entity registration, deletion SQL, retention periods, and foreign-key order
remain in the product.
After reset, the suite submits retained pending work and pulls from zero. The full snapshot must not overwrite a newer pending edit. It must remove server-backed parent, child, and pull-only rows whose tombstones are no longer available.
Tombstone-horizon migration
Existing adapters can omit fullResync; their types and cases are unchanged. A product that
enables finite tombstone retention should add the callbacks as one unit. Keep its current cursor
type and HTTP envelope, decode the horizon into SyncConformanceStaleCursor, and replace any split
"clear rows, then set cursor" path with one local transaction before enabling these cases.