All notable changes to tanstack-durable-object-sync are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. While pre-1.0, the public API may change between 0.x releases.
-
Typed oversize-frame handling (ADR-0018; part of #28). An oversize client mutation used to be dropped silently server-side (ADR-0012's
maxFrameBytesguard), surfacing only as a confirmation timeout — and in production Cloudflare's ~1 MiB edge cap on inbound WebSocket messages means the frame may never reach the DO at all. The transport now guards before sending: amut/callwhose encoded size exceeds the 1 MiB edge cap rejects immediately withMutationRejectedError(code"FRAME_TOO_LARGE"), so the optimistic overlay rolls back promptly. The server's silent drop stays as defense in depth. Outbound gains a warn-only fixed 1 MiB threshold: a larger encoded frame logs aconsole.warnwith size and collection but is still sent whole — column projection (#28a) remains the real fix for oversized full-row re-sends. The limits are infrastructure facts (Cloudflare's edge cap), not application preferences, so they are constants, not options. -
Transport reconnect policy (ADR-0016; fixes #25, #26). One option,
reconnectDelay?: number | ((attempt, closeCode?, closeReason?) => number | null)(null= stop), replacesreconnectDelayMs(breaking, pre-1.0: a number keeps the old meaning — the default policy's base delay). The default (defaultReconnectDelay) replaces the fixed interval with capped exponential backoff + full jitter (base 250 ms; cap 30 s; attempt counter resets on a successful open) and treats application close codes 4000-4999 as terminal, so an accept-then-close auth rejection (e.g. 4403) no longer retries forever. A terminal stop surfaces through the newonClosed(code, reason)hook, making auth closes distinguishable from transient drops. -
Cohosting docs moved to
recipes/cohosting.mdand re-grounded. The README's cohosting section shrinks to the pitch, the code sample, and a pointer; the recipe carries the rules, an honest account of what is verified and how (CI fake-host tests vs. source audit at pinned versions vs. the untested wake restore), and a reframed@cloudflare/actorsstory. Verified against@cloudflare/actors@0.0.1-beta.6: theActorclass hibernates fine on its own but won't cohost out of the box (itsSocketshelper takes over socket connections completely andActorclaims the DO-wide auto-response slot), while the Actors helpers (Alarms,Storage) compose cleanly with sync over a plainDurableObjectbase — which Actors' own examples document. Supersedes the 0.5.0 "host defect" phrasing.
- Subscriptions survive hibernation wake (ADR-0019; field report against
0.5.1). Hibernatable sockets survive a DO eviction by design, but the
subscription registry was instance memory: a wake restored the socket set
and nothing else, so an idle client's live queries went silently dead on a
still-open socket — its own mutations still confirmed while deltas fanned
out to nobody, and the client's only re-subscribe trigger (the close path)
never fired. Subscriptions are now written through to a durable
_sync_substable keyed by a per-socket id tag stamped at accept, and restored onto surviving sockets duringregisterSyncon every wake — before anything can dispatch or drain. No wire or client change: an unmodified 0.5.1 client against a fixed server recovers fully. Orphaned rows (a socket that dies withoutwebSocketClose) are swept during the existing compaction housekeeping; no idle timers (ADR-0006 invariant intact). Pinned bytests/hibernation.test.tsunder real evictions (evictDurableObject, unlocked by the vitest 4 migration — the eviction-based wake test issue #29 asked for), in five shapes: eviction after successful broadcasts, eviction before the first-ever broadcast, eviction of a cohosted base (tagged-restore branch, host socket untouched), a restored sub whose collection left the schema (reconciled:reset+ row dropped, healthy subs untouched), and a restored predicate that no longer compiles (socket closed with a non-terminal code so reconnect re-subscribes — aresetwould strand the query, adversarial review). Sockets accepted by a pre-fix build that survive an in-place upgrade carry no id tag and keep the old behavior (dead until reconnect) — a one-release sharp edge, documented in ADR-0019. - Rejection
codenow survives tx-dedup replay (#21). The dedup record persisted only the rejection message, so a client retrying the sametxIdgot the reason with no machine-readablecode— breaking code-based error handling on exactly the retry path it exists for._sync_seen_txgains anerror_codecolumn (added in place on wake for already-deployed DOs), and the replayedrejectedframe is now shaped identically to the original:{ code, message }when a code was recorded,{ message }otherwise. - BLOB columns no longer corrupt to
{}over the wire (ADR-0017, #27). workerd'sSqlStoragereturns BLOB values as bareArrayBuffer, which the msgpack encoder fell through toencodeMapon (and the JSON debug codec stringified as{}) — clients silently received an empty object, for snapshots and deltas alike. Both codecs now normalizeArrayBufferat emission, so BLOB columns arrive on the client as aUint8Arraywith the exact bytes.
registerSyncnow rejects tables with no usable internalrowid(ADR-0015). The cold-snapshot/fetch reader defaults toORDER BY rowidwhen the client sends noorderBy. AWITHOUT ROWIDtable has no rowid, so that read threwno such column: rowidand hung the subscriber; a table with a declaredrowidcolumn shadows the internal one, so the read would silently sort by that arbitrary column.assertSyncCompatiblenow rejects both loudly atregisterSync, alongside the existingINTEGER PRIMARY KEYguard. Ordinary rowid tables (the documentedid TEXT PRIMARY KEYpattern) are unaffected.SyncMixin's declaredwebSocketClose/webSocketErrornow match their implementation. Both were typed as returningPromise<void>while the mixin's overrides returnvoidsynchronously (there is nothing to await — both just drop bookkeeping for the closed socket). Retyped tovoid | Promise<void>, mirroring@cloudflare/workers-types's ownDurableObjectinterface.webSocketMessageis genuinelyasyncand is unaffected. Cosmetic: no runtime behavior changed, only the emitted.d.ts.
-
Syncable(Base)mixin — cohost sync on any Durable Object base (ADR-0015). The sync machinery is now a curried mixin factory,Syncable<Env, TUser>()(Base), so one DO can be both its framework's host (the Agents SDKAgent,@cloudflare/think'sThink, a bareDurableObject) and a tddc sync source — no dedicated sync DO, no mirror write. Exposed from the root and a./server/mixinsubpath. Sync sockets carry a reserved tag and a plain attachment and claim only the/_syncpath; all other traffic delegates to the host base, so the two protocols never cross (proof: partyserver's__pkfiltering).Actor(@cloudflare/actors) is documented as unsupported because itsSocketshelper adopts foreign sockets on wake. See the README "Cohosting" section. -
Optional Standard Schema validation (ADR-0014). A collection's
insert.schema(the row schema, which also infers the collection's Row) andupdate.schema(a partial patch schema), and a command's schema, are checked at runtime and rejected loudly on failure. Any~standardlibrary works (zod, valibot, arktype) and the framework adds no validator dependency. It is a gate, not a parser: the original value flows to handlers, so schemas must not rely on transforms, defaults, or coercion. Seerecipes/zod-standard-schema-collections.md.
-
SyncDurableObjectis nowSyncable()(DurableObject)— zero API change. All existingextends SyncDurableObject<Env, Claims>code, includingthis.sql,this.registerSync,this.runSyncedWrite, and an overridableparseAttachment, keeps compiling and behaving identically to 0.4.0 (the two DO-global side effects —ping/pongauto-response andPRAGMA case_sensitive_like = ON— stay ON for this base; they default OFF over any other base, opt in withthis.sync.configure). The internalsqlgetter was removed from the mixin because it shadowed the host'ssqltagged-template method; reachthis.ctx.storage.sqldirectly on a non-DurableObjectbase. -
Rejection reasons are surfaced uniformly for mutations and commands (revises ADR-0012 D3). An
authorizethrow or a schema validation failure now reaches the client with its reason (validation failures carry aVALIDATIONcode); onlyexecuteerrors stay sanitized. Previously a command'sauthorizeerror was sanitized like itsexecute, unlike a mutation's.
- Authoring is now an object schema, not a builder (ADR-0014).
new SyncRegistry().defineCollection().defineMutation().defineCommand()is replaced bydefineSync<User, Env>(), which binds identity/env once and returns{ collection, command, schema }. Mutations are a closed insert/update/delete trio co-located on the collection (mirroring@tanstack/db'sonInsert/onUpdate/onDelete— a custom mutation type is now structurally unrepresentable), superseding ADR-0001 D11 and absorbing ADR-0010's row manifest: the row type lives on the collection (sync.collection<Message>({ pk })) instead of a thirdSyncRegistrygeneric. The DO registers the schema value withthis.registerSync(schema). Breaking, no compat shim.
- Typed commands, end to end (ADR-0014).
export type Api = typeof schemais the whole client contract:new WebSocketTransport<Api>()exposes a typedtransport.call.<command>(args)proxy plus a typed low-levelsendCall(name, args)(txId generated internally viacrypto.randomUUID()), anddoCollectionOptions<Api, "table">({ … })infers the row type from the schema — one source of truth across server and client. examples/multi-do— a two-Durable-Object example: one transport per DO, a ReactSyncProvider/useSynckeyed by DO so command namespaces never collide, and a client-side cross-DO feed.
- A filtered subscription's membership no longer depends on which path
decided it (ADR-0013). The SQL snapshot and the JS delta/catch-up evaluators
disagreed on two operators, so two clients could see different rows for the
same
wheredepending on connection timing:necrashed the delta path and hung the client. The SQL floor acceptednebut@tanstack/db's evaluator has none(not-equal isnot(eq(...))); its compile error escapedhandleSubuncaught, so noresetwas sent.neis now off the floor and rejected withresetlike any unsupported operator, and a defensive guard turns any predicate-compile failure into aresetrather than a hang. Usenot(eq(...))for not-equal (unchanged for real clients).likewas case-insensitive in the snapshot but case-sensitive in deltas. The DO now setsPRAGMA case_sensitive_like = ON, making SQLiteLIKEmatch@tanstack/db's case-sensitivelikeon every path. (#17)
- Operator floor for server-side filtering dropped
ne— it is noweq, gt, gte, lt, lte, like, in, and, or, not, exactly the set the SQL and JS evaluators agree on row-for-row.likeis now case-sensitive. (#17)
- Wire-input hardening at the server boundary (ADR-0012). Frame-shape guards drop malformed frames — the socket survives and answers the next valid frame — and inbound limits bound frame size and per-connection subscription count. (#15)
- A failed initial connect no longer wedges the client transport. A
WebSocket that never
open()s fires nocloseevent, so the auto-reconnect couldn't run and the cached rejectedconnectPromisewedged the transport permanently — every laterconnect()returned the same rejection. On a failed open the transport now clears the promise and re-arms the reconnect while subscriptions are live. (#12)
- Reconnect catch-up no longer issues an N+1. Delta hydration batches
keyed reads in chunks (≤64) and the per-table changelog read uses the
(tbl, seq)composite index, so a 500-key catch-up issues ~8 queries instead of 500. Behavior is unchanged. (#14)
- Mutation
executeerrors are sanitized. A failedexecutenow returns a generic "mutation failed" rather than leaking SQLite/internal detail to the client; authorize errors still pass through. (#15)
- Server error-path and wire-level test coverage expanded (#11); dead
snapshotAllremoved and CDC trigger DDL identifiers quoted (#13).
- Catch-up reinsert no longer wedges the client. A key deleted-and-
reinserted while a client was away arrives in the catch-up as op=
insertfor a key the client still holds; TanStack's sync write throwsDuplicateKeySyncErroron that, aborting the whole catch-up transaction. The adapter now applies a held-key insert as the upsert it semantically is (the move-in update-upsert contract, ADR-0002 C4). - Cursor barrier (C1′). A snapshot or catch-up served on a socket
with still-buffered coalesced deltas could advance the client's cursor past
an undelivered write (multi-collection reconnect; drop before the tick lost
the write). The server now flushes the socket's pending deltas before any
synchronous cursor-advancing emission — ADR-0002 C1 generalized from
committedto all cursor boundaries. - Reconnect window no longer kills subscriptions. The
reconnectingflag was set inside the reconnect timer, so a mutation fired within the reconnect delay of a drop established the fresh socket before the timer ran — with no resubscribe, leaving every subscription silently dead on the new socket (and the late timer wedged the flag). The flag is now set when the reconnect is scheduled, so whichever connect wins — timer- or demand-driven — resubscribes from the cursor.
- Typed mutations (ADR-0010).
SyncRegistrytakes a third generic — a collection-row manifest — so handlers are fully typed without casts:new SyncRegistry<TUser, Env, { messages: Message }>()typespk(must be a column) and each handler'sop.colsper op (insert→Message,update→Partial<Message>,delete→none). Purely type-level; the untyped two-generic form still works (op.colsfalls back tounknown). - Changelog time-based retention (ADR-0009). A new
changelogRetentionMsknob (default 2 days) prunes_sync_changesrows older than the window, so the log is bounded by age, not just key-cardinality. A client reconnecting from beyond the surviving floor now receives areset+ full snapshot instead of an incremental delta. SetchangelogRetentionMs: nullto disable retention (compaction-only, the prior behavior).
- Renamed
Registry→SyncRegistry. Breaking, no compat shim. The old name was too generic and clashy; the public surface is uniformlySync*(SyncDurableObject,runSyncedWrite,registerSync). Update your import andnew SyncRegistry(...). registerSyncnow reconciles CDC triggers to the registry instead of only adding them: triggers for a collection you've removed from the registry are dropped on the nextregisterSync, so an orphaned table stops firing capture triggers into_sync_changes. Trigger reap only — existing change rows are left untouched. (ADR-0008)
-
Author-owned schema;
registerSyncwires the sync. Breaking. Collection definitions are now{ table, pk }—ddlis removed. You create your tables yourself (raw DDL, Drizzle, any migrator), then callthis.registerSync(registry)— typically in your constructor'sblockConcurrencyWhile, after migrating. It validates each table (onePRAGMA: the pk is a soleTEXTclient key — D9) and installs the CDC triggers. LazyinitRegistry-on-fetchis gone; schema exists before the first event. Forget the call →this.registrythrows loud. This also retiresrunSyncedWrite's "caller ensures init" caveat. See ADR-0007. -
The cursor
fetchframe now mirrors@tanstack/db'sLoadSubsetOptions. Breaking wire change (client and server ship together). The frame carries a basewhereplus a rawcursor: { whereFrom, whereCurrent }— TanStack's ownCursorExpressionsnames and semantics (the cursor expressions exclude the basewhere) — replacing the previous privatewhere/tiesfields that combined the predicates client-side. The server now composesbase AND whereCurrent(ties, unbounded) andbase AND whereFrom(next page, bounded bylimit). Behaviour is unchanged (identical compiled SQL); the win is traceability to upstream. A malformed cursor (a missing half) is now rejected loudly instead of degrading to an unbounded scan. See ADR-0005. -
Collection pk validation accepts TEXT affinity, not the literal
"TEXT".registerSyncnow admits any TEXT-affinity pk (TEXT,VARCHAR,CHAR,NVARCHAR, …) so ORM/migrator-generated DDL composes;INTEGER(rowid alias) and other non-TEXT affinities are still rejected loudly (they'd break optimistic id parity). Part of ADR-0007.
runSyncedWrite(fn)— aprotectedSyncDurableObjectprimitive for server-originated writes (an agent inserting a row, a webhook, a cron/alarmjob, an admin edit, a bulk seed): apply a raw synchronous SQL closure in a transaction, then broadcast the resulting CDC to connected clients. Outside the client mutation flow — notxId, no receipt, no dedup (idempotency rides the collection's mandated stable keys). See ADR-0006.examples/board— an at-scale stress example: 5,000 tasks on one DO, bounded window load,useLiveInfiniteQuerycursor scroll-back, and a mutable order key (updated_at) so voting/starring bumps a task to the top (move-in via the always-emit upsert). A server-side firehose makes the deferred bounded-window-under-churn limitation visible:loadedclimbs pastwindow.- A real-tie integration test (unbounded boundary ties + limited next page + base
predicate composed into both halves) covering the cursor
fetchagainst the DO. - A move-in integration test: a cold row bumped server-side arrives via the
no-
wherelive sub and upserts into the collection (ADR-0002 C4).
afterCommitpost-commit hook +envin handler context. Mutations gain an optionalafterCommit(ctx)that runs fire-and-forget viawaitUntilafter the commit and receipt — the sanctioned home for external side effects a synchronousexecutecan't do (delete an R2 object, enqueue a job). It's isolated (a throw is logged, never affects the committed mutation) and owns its own idempotency. Handler contexts (authorize/execute/afterCommitand commandexecute) now receive the DO'senv, typed via a newEnvgeneric onRegistrythat defaults tounknown(existingRegistry<TUser>unchanged). See ADR-0004.- Bounded initial load for on-demand windows. A live query's
orderByandlimitare forwarded to the server so the initial snapshot is the bounded window (e.g. the most recent N rows) rather than the wholewheresubset. The live subscription's predicate stays thewhereclause, so entering rows are still delivered. - Cursor load-more (scroll-back). Extending a windowed query past its first
page issues one one-shot paginated
fetch(newfetch/pagewire frames) carrying both halves of the cursor double-read — boundary ties (ties, unbounded) and the next page (where, bounded bylimit), each combined with the basewhere. The server reads both at a singleseq(atomic), and no new live subscription is taken — the window's deltas already flow over the existingwheresub. See ADR-0003.
- Cursor load-more no longer resurrects a concurrently-deleted row. The
earlier two-frame double request read the ties at one
seqbut applied them after a deferred merge; a live delete landing in between let the stale tie re-insert the deleted row, with no future delta to correct it. The double-read is now one atomicfetch, so the page applies in stream order before any later delta. See ADR-0003. - Cursor load-more no longer throws on overlapping boundary rows. Page rows
are written insert-if-absent, so a boundary tie already in the window (or a row
a concurrent live delta already refreshed) is skipped rather than re-inserted —
which would otherwise throw
DuplicateKeySyncErrorand abort the open sync transaction. The livewheresubscription stays the source of truth for rows currently in the collection. - Rejected subscriptions no longer hang
preload(). Aresetwith nosnap-end(the server's response to an unsupported predicate or unknown collection) now resolves the subset's load promise instead of leaving the live query waiting indefinitely. - On-demand
orderByIR shape.orderByclauses from real live queries ({ expression, compareOptions }) were not recognised by the SQL compiler, so server-side ordering was silently dropped and the wrong rows were returned for a bounded window. The compiler now accepts the live-query clause shape.
Initial release — sync a TanStack DB collection to a Cloudflare Durable Object over a single WebSocket. Server-authoritative, single-writer, no CRDTs.
- Single-ordered-stream sync. Change-data-capture via SQLite triggers into
one per-DO change log; one monotonic
seqcursor drives live deltas, reconnect catch-up, and write confirmation. The client tracks a singleappliedSeq— no second acknowledgement channel. - Optimistic mutations with single-stream confirmation (
awaitSeq):mut(atomic row transactions) andcall(named commands), exactly-once viatxIddedup. Mutationexecuteruns insidetransactionSync;authorizeruns before it. - Client-supplied keys enforced at
defineCollection(ULID/UUIDv7); the optimistic id equals the confirmed id. - Filtered subscriptions. A
wherepredicate (a@tanstack/dbBasicExpression) is evaluated server-side with@tanstack/db's own compiler, so operators match the client exactly; move-in/move-out handled without a before-image. Client-side write-outside-filter preflight (WriteOutsideSubError). - Subset shaping —
where/orderBy/limit/offsetlowered into SQLite (operator floor: eq, ne, gt, gte, lt, lte, like, in, and, or, not); un-lowerable predicates are rejected, never silently full-scanned. - On-demand subsets (
syncMode: 'on-demand') — load only the subsets your live-querywhereclauses request; each distinctwhereis one refcounted server subscription, released on the last unload. Writes outside every loaded subset are confirmed without stranding an optimistic row. - Egress coalescer — per-
(sub, key)last-write-wins on a tunable tick, collapsing high-rate writes (e.g. streaming tokens) to one delta per tick; hibernation-native (no timer when idle). - Reconnect catch-up — auto-reconnect resubscribes from the applied cursor; the server serves a windowed delta or, past the retention floor, a reset + snapshot.
- Compaction-defined retention — opportunistic (every N writes, off the
response path via
waitUntil; no idle-DO wakeups): the change log collapses to latest-op-per-key, with independent time-based dedup GC. - Multiplexing — many collections on one DO share a single WebSocket.
- Client IVM — live queries (joins, filters, aggregates) run client-side
via
@tanstack/db; the DO stores and emits, never runs IVM. - Binary wire — MessagePack frame codec (JSON debug fallback) + a tagged value codec preserving bigint/Date/NaN/±Infinity/-0/undefined/Uint8Array.
- Hibernating WebSockets —
acceptWebSocket+ auto-response ping/pong; per-socket identity viaserializeAttachment. - Examples —
examples/chat(eager, multi-tab live sync) andexamples/on-demand(on-demand subsets), both verified in a real browser.
- Windowed pagination (
orderBy/limit/cursor double-request with server-side window maintenance under churn). isWhereSubsetcontainment dedup of overlapping subsets.