Repository navigation
Releases: grrowl/tanstack-durable-object-sync
Release list
v0.7.0 — SSR, and on-demand rebuilt for TanStack DB 0.9
Added
- SSR support (experimental, ADR-0011) — built on TanStack DB's merged
SSR API (DbClientdehydrate()/hydrate(), PR
#1564, shipped in
@tanstack/db0.8.0).- Server:
readSyncSnapshot(req, request)— one consistent
{rows, cursor}read over the DO binding, no WebSocket. The required
requestruns throughparseAttachment: one auth gate for the socket
and the read path. The cursor is a durable high-water mark
(max(currentSeq, drainCursor)— robust to retention pruning);"0"
honestly means "no resume point". RPC rows normalize BLOB
ArrayBuffer → Uint8Arrayfor wire-codec parity (ADR-0017). - Client:
SsrSnapshotTransport(read-only; per-request; swapped at
the new structuralTransport<Api>seam), syncMeta cursor round-trip
({v, cursor, where-fingerprint}; fail-loud-but-safe import/merge),
sinceon the first sub,seedCursor(a late chunk regresses and
replays via a forced reconnect), always-armed eager snapshot reconcile
(authoritative set semantics — no flash-to-empty, no stranded deletes),
on-demand transient catch-up with honest truncate for unresumable rows. - Wire (additive):
uptodategains optionalsub(a catch-up's
terminal is sub-scoped); the firstsubmay carrysince.
- Server:
- New upstream contracts adopted:
commit()receipts
(SyncAppliedReceipt, 0.8.5) — subset loads settle only once rows are
visible;markError(0.8.2) — a failed first connect failspreload()
loud with the cause instead of hanging (a retriedpreload()recovers);
per-subset load failures reject that subset's promise (0.8.4);
withCollectionConfigFactory(0.8.0) —doCollectionOptionsconfigs work
ascollectionOptions(id, …)descriptors with fresh adapter state per
DbClient.
Changed
- Peer dependency:
@tanstack/db >= 0.8.6(was>= 0.6.0). Breaking. The SSR
hooks shipped in 0.8.0; 0.8.5 carries thecommit()-receipt contract and
descriptor reuse this adapter adopts; 0.8.6 is the first release whose
module evaluation is Worker-safe (0.8.5 callscrypto.getRandomValues()at
module scope, so a Worker rendering SSR fails to start). The range stays
uncapped (ADR-0022). The 0.6-era API is otherwise unchanged: the full
pre-lift suite passed on 0.8.5 without modification. - The transport ignores STREAM frames from an abandoned socket
(identity-guarded message dispatch): only the current socket speaks for the
stream; dropped frames are re-covered by the resubscribe catch-up from the
applied cursor. Mutation receipts (committed/rejected) still settle their
waiters from a stale socket — they are not re-covered by any replay — but
never advance the cursor. Apagefrom a stale socket fails its fetch: the
fresh socket's replay may already have deleted a row it carries (ADR-0023). WebSocketTransport.close()is now revivable (ADR-0020). A later
connect()clears the intentional-close latch, restoring auto-reconnect and
onCloseddelivery on a reused transport. Previouslyclose()was permanent
across aconnect(): a revived transport (connection pools reuse instances)
silently lost both. Code that relied onclose()being permanently terminal
must not re-connect()the same instance.- The injectable
opengains an optionalAbortSignal(ADR-0020).
Additive and backward-compatible — existing openers compile and run
unchanged — but a custom opener should honour it (close its socket, reject)
soclose()during an in-flight handshake frees a still-CONNECTING socket. - New export
ConnectionLostError(ADR-0021, issue #39). Settles an
in-flightmut/callwhose socket dropped unexpectedly before its receipt
arrived, when no reconnect+replay can resolve the true outcome — catchable and
instanceof-distinct fromMutationRejectedError,TransportClosedError, and
the generic confirmation timeout. The type is the contract: an app catches it
to hold its optimistic overlay (the outcome is unknown) rather than roll back. - Examples updated to
@tanstack/db0.9 /@tanstack/react-db0.4, each bundling exactly one@tanstack/db; CI now typechecks, builds and boots every example (ADR-0024). - On-demand: a released query's rows leave the collection (ADR-0023).
Releasing a query releases its server subscription and removes the rows only
it kept current, unless another loaded query still holds them. Before, they
stayed with nothing keeping them current, so a row deleted meanwhile came back
after a reload. A row with a pending optimistic write stays visible until the
write settles. Behaviour change: code that read a released query's rows
from the collection no longer finds them.
Fixed
- On-demand requests load exactly what they ask for (ADR-0023). A subset was
identified by itswherealone, so two queries with one filter and different
orders or limits shared the first one's bounded snapshot (the second showed
the wrong rows), a growing window without an index never reached the Durable
Object, and on@tanstack/db0.9 the new tie, prefix and full-subset requests
were answered with the first page, so windows stopped paging. Identical
requests still share one load; a request that an existing subscription
already keeps current is a one-off fetch. - On
@tanstack/db0.9, an ordered window no longer loses rows or stops paging
(examples/board). 0.9 requests the rows tied at a page boundary
separately; each became a live server subscription (piling up against
maxSubsPerSocket) whose move-out delete removed rows the window's own
subscription still held, so a new row inserted at the top of the window was
lost once its write was confirmed. Tie requests are now one-off fetches. - A row another loaded query still holds is no longer deleted by a
neighbour's move-out. Every subscription gets a delete for a changed row it
does not match; the adapter applied it even when another subscription still
matched the row. A row now leaves only when no subscription holds it. - A refused on-demand subscription no longer truncates the collection. An
unsupported predicate, the per-socket subscription cap or an unknown
collection wiped every other loaded subset on 0.8, and on 0.9 looped: the
truncate replay re-subscribed the refused predicate hundreds of times a
second. A refusal now settles only its own load. - A reload no longer receives a released subscription's in-flight snapshot.
Subscription ids were reused per filter; each subscription now gets a fresh id. - Cleaning up an on-demand collection no longer closes a shared transport. It
unsubscribes what it owns, and other collections on the transport keep
receiving changes (0.9.1's GC reclaim runs cleanup more often). Behaviour
change: the transport is the caller's; close it withtransport.close()
when you are done with it. - A subscription that drops before its first snapshot now loads. On
reconnect the transport resubscribed it from the shared cursor, so the Durable
Object answered a catch-up: its rows never arrived and its load never settled
(eager or on-demand, once another subscription had advanced the cursor). It
now restarts from a snapshot, undoing what a partial first snapshot
delivered. An SSR hydration catch-up interrupted the same way resumes from its
own dehydrated cursor. unloadSubsetis idempotent and never throws, and a page whose request was
aborted installs nothing (@tanstack/db0.9 subset contracts).#sendno longer throws an uncaughtCan't call send() after close()when a
client subscribes then closes before the snapshot finishes streaming (normal
churn: dispose, navigate-away, StrictMode teardown, forced reconnect). The
server now skips sends on a non-OPENsocket and treats a post-close send as
a benign no-op — the socket'swebSocketClosealready tore down its subs.
Covers theBroadcasteregress path too (it routes through the same#send).
Outbound-only; no state impact, no behavior change for OPEN sockets (issue #40).connect()never resolves disconnected (ADR-0020, issue #37). The
close-epoch discard previouslyreturned, resolvingconnect()with no
socket adopted; the awaitingsubscribe/sendMut/fetchthen sent on a
null socket and threw, floating an unhandled rejection and leaving the
collection silently empty.connect()now re-dials a revived transport or
rejects the new typedTransportClosedError; a racingsubscribeeither
lands its frame on the next connection or rejects typed (routed to
markError, sopreload()fails loud and recovers on retry).close()can abort a socket whoseopen()is still in flight (ADR-0020,
issue #38). Previouslyclose()disposed throughthis.ws, which is
nullduring the handshake, so a slow or never-completing handshake leaked a
CONNECTING socket forever.close()now aborts the in-flightopen()via the
AbortSignal; the default browser opener honours it, and the close-epoch
discard remains the backstop for signal-ignoring openers.unsubscribeduring an in-flightsubscribe's connect no longer sends the
sub after the socket opens — previously the server persisted a ghost
subscription (ADR-0019) with no local consumer until the socket dropped.
Pre-existing; surfaced by the SSR lift's adversary review.- An in-flight
mut/callis no longer abandoned to its generic timeout when
the socket drops unexpectedly (ADR-0021, issue #39). Server-side, the durable
commit + broadcast run before thecommittedsend, so a drop in that window
used to time out and roll back a write that had already succeeded. Now, on an
unexpected drop with subscriptions active, the transport HOLDS each in-flight
mut/calland REPLAYS it on reconnect; the server's dedup ...
v0.6.0 — subscriptions survive hibernation wake
Added
-
Typed oversize-frame handling (ADR-0018; part of
#28).
An oversize client mutation used to be dropped silently server-side
(ADR-0012'smaxFrameBytesguard), 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 with
MutationRejectedError(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 new
onClosed(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.
Fixed
- 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 sametxId
got the reason with no machine-readablecode— breaking code-based error
handling on exactly the retry path it exists for._sync_seen_txgains an
error_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.
v0.5.1
Patch release: reject tables with no usable internal rowid at registerSync (ADR-0015), and align SyncMixin's WebSocket handler types with their implementation.
Fixed
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.
Full Changelog: v0.5.0...v0.5.1
v0.5.0
Syncable(Base) mixin — cohost sync on any Durable Object base (ADR-0015) — plus optional Standard Schema validation (ADR-0014) and uniform rejection reasons for mutations and commands.
Added
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.
Changed
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.
Full Changelog: v0.4.0...v0.5.0
v0.4.0
Object-schema authoring (defineSync) replaces the builder, and typed commands run end to end (ADR-0014). Breaking, no compat shim.
Changed
- 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.
Added
- 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.
Full Changelog: v0.3.3...v0.4.0
v0.3.3
Patch release: filtered-subscription predicate parity (ADR-0013).
Fixed
- A filtered subscription's membership no longer depends on which path decided it. 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 withreset, 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. Thelikepattern must be a string literal.
Changed
- Operator floor for server-side filtering dropped
ne— 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.
Full Changelog: v0.3.2...v0.3.3
v0.3.2
Bug-fix patch from the deep-audit follow-up. Published to npm as `tanstack-do-db-collection@0.3.2`.
Added
- Wire-input hardening at the server boundary (ADR-0012) — frame-shape guards drop malformed frames (the socket survives and answers the next valid frame); inbound limits bound frame size and per-connection subscription count. (#15)
Fixed
- A failed initial connect no longer wedges the client transport. A WebSocket that never
open()s fires nocloseevent, so auto-reconnect couldn't run and the cached rejectedconnectPromisewedged the transport permanently. On a failed open the transport now clears the promise and re-arms the reconnect while subscriptions are live. (#12)
Changed
- 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 — a 500-key catch-up issues ~8 queries instead of 500. Behavior unchanged. (#14)
Security
- Mutation
executeerrors are sanitized — a failedexecutereturns a generic "mutation failed" rather than leaking SQLite/internal detail; authorize errors still pass through. (#15)
Internal
- Server error-path + wire-level test coverage expanded (#11); dead
snapshotAllremoved and CDC trigger DDL identifiers quoted (#13).
Not included: the ne/like predicate-parity divergence (parked pending an ADR decision; will ride a later patch).
Full Changelog: v0.3.1...v0.3.2
v0.3.1
Three pre-existing reconnect/catch-up-path fixes, surfaced during the SSR review but independent of it — they run against released @tanstack/db and ship with no vendored dependencies.
Fixed
- 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.
Full changelog: https://github.com/grrowl/tanstack-do-db-collection/blob/main/CHANGELOG.md
v0.3.0
Added
- 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).
Changed
- 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)
Full changelog: https://github.com/grrowl/tanstack-do-db-collection/blob/main/CHANGELOG.md
v0.2.0
Changed
-
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 own
CursorExpressionsnames 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 by
limit). 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.
Added
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 cursorfetchagainst 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).