@growth-labs/sync-client
v0.8.1
Published
Runtime-neutral sync-v2 primitives for durable offline queues, normalized snapshots, change-feed reconciliation, reconnect scheduling, and the shared Fulcrum Projects conformance fixture. Published on the public npm registry through the tagged release wor
Readme
@growth-labs/sync-client
Runtime-neutral sync-v2 primitives for durable offline queues, normalized snapshots, change-feed reconciliation, reconnect scheduling, and the shared Fulcrum Projects conformance fixture. Published on the public npm registry through the tagged release workflow.
Construction and lifecycle
Inject persistence, fetch, and a socket connector rather than relying on browser globals.
Hydrate persisted authoritative state and queued mutations, start snapshot/change draining and
socket nudges, enqueue optimistic mutations, flush FIFO batches, and stop by aborting in-flight
work and cancelling reconnect timers. Both the composed client and createHttpTransport require an
HTTPS base URL and snapshot its validated origin at construction. Authenticated HTTP requests fail
closed on redirects. Browsers authenticate sockets with same-origin session cookies because native
WebSocket cannot add an Authorization header. Device/native clients inject bearer authorization
in their socket connector and async HTTP header provider.
Install and import the two public entrypoints:
pnpm add @growth-labs/sync-clientimport {
createHttpTransport,
createSyncClient,
type SocketConnector,
type SyncPersistence,
} from '@growth-labs/sync-client'
import {
type ConformanceHarness,
runSyncConformance,
syncConformanceFixture,
} from '@growth-labs/sync-client/conformance'
declare const persistence: SyncPersistence
declare const socket: SocketConnector
declare const reportSyncError: (error: unknown) => void
const baseUrl = new URL('https://projects.example')
const transport = createHttpTransport({
baseUrl,
fetch: globalThis.fetch,
headers: async () => ({}), // same-origin browser session cookies
})
const client = createSyncClient({
date: '2026-08-28',
baseUrl,
persistence,
transport,
socket,
onError: reportSyncError,
})
await client.hydrate()
await client.start()
const handle = await client.enqueue({
collection: 'projects',
kind: 'project.create',
target: 'project:new',
baseVersion: 0,
payload: { name: 'Offline project' },
optimistic: [],
})
await client.flush()
void handle.settled
client.stop()
export const runCanonicalContract = (harness: ConformanceHarness) =>
runSyncConformance(harness, syncConformanceFixture)The browser socket adapter should construct the supplied exact /api/v2/ws URL and forward
parsed JSON messages to onMessage; its same-origin cookies supply session auth. A device/native
adapter owns bearer socket authorization. start() serializes socket-triggered reconciliation,
and stop() closes the socket, cancels reconnect, and aborts the active drain. The required
onError callback receives malformed nudges plus connector and drain failures from the current
socket generation; callbacks retained by a closed generation are ignored. A failed socket-open
reconciliation retains its recovery obligation and makes one retry after the configured initial
backoff, scoped to that live lifecycle and socket generation. A failed ordinary nudge likewise
retains its highest requested sequence and retries after the initial backoff while that lifecycle
and socket generation remain current.
applied, duplicate, conflict, and rejected are terminal. retryable and unknown network
outcomes retain the exact client ID, mutation ID, collection, kind, target, base version, and
payload for replay. Every enqueue requires a local collection anchor and a dense optimistic patch
array. That anchor is persisted with the queued mutation but is not added to the wire request; it
lets terminal server state update the correct authoritative collection even when optimistic is
empty. A null terminal result uses a unique optimistic entity in that collection when present,
then falls back to the wire request's
target only when it is a valid entity ID for that collection. Parent-scoped null results without
an entity anchor settle without inventing a deletion; the next reconciliation supplies canonical
server state. A non-null result upserts only its returned entity ID.
Authoritative state is stored separately from optimistic FIFO overlays: a terminal rollback removes
only that overlay, applies validated server state when present, and reapplies later overlays without
clobbering them. A delayed terminal result updates authoritative state only when the authoritative
revision is unchanged since its batch was sent, so a consumed feed transition wins even when its
final entity value matches the pre-send value. Duplicate results settle the durable queue without
projecting their historical original result; the change feed remains authoritative. A non-null
authoritative result must match the anchored collection's snapshot shape or one of the four
mutation-only entity projections; a generic domain entity cannot use a collection-valid ID to
bypass that binding. Snapshot, change, and
mutation-result transport documents are cloned once immediately after receipt and validated once;
only that detached observation enters serialized state work, and retained state or settlement data
is derived from it. Snapshot cursors, change cursors and sequences, and socket nudge sequences must
also be safe JavaScript integers before they can affect state.
Immutable contract provenance
Generated declarations, validators, and fixture exports come from
fulcrum-labs/fulcrum-projects@013b94c0e67e362c8a1ce47178c345bf161dc194, exact aggregate
digest ff381e92aec97fd9c315be1bd8a56688dde15a45e35f03128a29856ed3097063.
The bundle stores all 20 upstream files as exact UTF-8 source strings. A separately shipped Git
proof sidecar binds those bytes to the raw pinned commit and four linked trees, so --check needs
neither a network connection nor a private upstream checkout. Self-contained generated validators
are authoritative at runtime; generated static declarations provide useful schema-derived unions.
Regenerate only from the pinned Git object:
pnpm --filter @growth-labs/sync-client generate:contracts -- --write --source-repo /absolute/path/to/fulcrum-projects --source-sha 013b94c0e67e362c8a1ce47178c345bf161dc194Use runSyncConformance with an injected ConformanceHarness to execute the same canonical
scenarios as the Go server. The runner resolves captures internally, preserves exact response
bytes for bodyText/bodyEmpty, and keeps parsed JSON separate for document assertions.
It executes a detached validated plan, passes a separate scenario copy to beginScenario, and
rejects any request path that escapes the fixed https://conformance.invalid sentinel origin.
refresh() requests a new snapshot for the configured date while retaining the queue and
mutation handles. Failed, malformed, wrong-date, and regressing-cursor reads leave valid
state intact. A read racing a settled mutation or applied feed change retries against
the newer authoritative revision. Consumers handle the returned rejection; the method
does not clear visible state or change connection health.
Personal work and selected-date recording contracts
Version0.6 adds personal task title/status updates, explicit duplicate policy and atomic
initial notes, same-kind personal hierarchy, recording-plan deletion and date-scoped
recording refresh. Generated queue admission also interprets the canonical schema's
x-fulcrum-mutation-semantics: UTF-16 text limits and exact target/payload date bindings.
These rules are read from the pinned source bundle and shared with Go validation;
unknown or malformed rule metadata fails generation. Ordinary JSON Schema retains
field types, enums, date formats and same-kind parent constraints. Other pre-existing
server semantic and authorization rules remain server-owned.
Recording source failures retain the same queued request and mutation identity for retry. This release does not add an external feed, credentials, application ownership rules or a separate transport.
Dataset epochs and stale-intent review
Version 0.8 binds every queued intent to the authoritative dataset epoch observed
at enqueue. Persisted v1 state remains readable: an omitted epoch means zero,
including on legacy queued requests. It is never inferred from a newer snapshot.
Wire batches contain a contiguous FIFO prefix from one epoch, and HTTP changes
requests send both since and epoch. Custom SyncTransport adapters must forward
the optional third changes(since, signal, epoch) argument; the original first two
arguments remain compatible.
A new snapshot epoch or HTTP 409 sync_epoch_mismatch quarantines obsolete intents.
client.getState().quarantined preserves their original mutation IDs, base versions,
payloads, collection anchors, optimistic patches and epochs for explicit review.
These entries are excluded from sends and optimistic views. Quarantined handles
reject with StaleSyncMutationError; handle consumers must handle rejection as well
as normal wire receipts. The required onError callback also receives that error.
epochRecoveryRequired indicates that a fresh snapshot is still needed. Quarantine
and the recovery marker are saved before the recovery read. Failed recovery leaves
new enqueue/flush operations blocked; call refresh() to retry the snapshot. A
persistence failure also blocks the running client and exposes the recovery marker,
even if the durable write could not complete. A snapshot at the rejected epoch
cannot unblock recovery. Normal network failures without a mismatch retain their
existing identity-preserving retry behavior.
After recovery, present the quarantined intents for review. New deliberate actions
can be enqueued against the current snapshot. Explicitly discard reviewed records
with await client.discardQuarantined(mutationIds); unknown or duplicate IDs reject
the entire discard. There is no automatic replay, relabelling, or conversion of
quarantined intents. Persist quarantine alongside the rest of the supplied state.
An import rollback also receives a fresh epoch, so it cannot revive old intents.
