You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
v0.7.0 — SSR, and on-demand rebuilt for TanStack DB 0.9
SSR support (experimental, ADR-0011) — built on TanStack DB's merged
SSR API (DbClientdehydrate()/hydrate(), PR #1564, shipped in @tanstack/db 0.8.0).
Server:readSyncSnapshot(req, request) — one consistent {rows, cursor} read over the DO binding, no WebSocket. The required request runs through parseAttachment: 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 → Uint8Array for wire-codec parity (ADR-0017).
Client:SsrSnapshotTransport (read-only; per-request; swapped at
the new structural Transport<Api> seam), syncMeta cursor round-trip
({v, cursor, where-fingerprint}; fail-loud-but-safe import/merge), since on 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):uptodate gains optional sub (a catch-up's
terminal is sub-scoped); the first sub may carry since.
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 fails preload()
loud with the cause instead of hanging (a retried preload() recovers);
per-subset load failures reject that subset's promise (0.8.4); withCollectionConfigFactory (0.8.0) — doCollectionOptions configs work
as collectionOptions(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 the commit()-receipt contract and
descriptor reuse this adapter adopts; 0.8.6 is the first release whose
module evaluation is Worker-safe (0.8.5 calls crypto.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. A page from 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 onClosed delivery on a reused transport. Previously close() was permanent
across a connect(): a revived transport (connection pools reuse instances)
silently lost both. Code that relied on close() being permanently terminal
must not re-connect() the same instance.
The injectable open gains an optional AbortSignal (ADR-0020).
Additive and backward-compatible — existing openers compile and run
unchanged — but a custom opener should honour it (close its socket, reject)
so close() during an in-flight handshake frees a still-CONNECTING socket.
New export ConnectionLostError (ADR-0021, issue #39). Settles an
in-flight mut/call whose socket dropped unexpectedly before its receipt
arrived, when no reconnect+replay can resolve the true outcome — catchable and instanceof-distinct from MutationRejectedError, 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/db 0.9 / @tanstack/react-db 0.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 its where alone, 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/db 0.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/db 0.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 with transport.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.
unloadSubset is idempotent and never throws, and a page whose request was
aborted installs nothing (@tanstack/db 0.9 subset contracts).
#send no longer throws an uncaught Can'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-OPEN socket and treats a post-close send as
a benign no-op — the socket's webSocketClose already tore down its subs.
Covers the Broadcaster egress 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 previously returned, resolving connect() with no
socket adopted; the awaiting subscribe/sendMut/fetch then 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 typed TransportClosedError; a racing subscribe either
lands its frame on the next connection or rejects typed (routed to markError, so preload() fails loud and recovers on retry).
close() can abort a socket whose open() is still in flight (ADR-0020,
issue #38). Previously close() disposed through this.ws, which is null during the handshake, so a slow or never-completing handshake leaked a
CONNECTING socket forever. close() now aborts the in-flight open() via the AbortSignal; the default browser opener honours it, and the close-epoch
discard remains the backstop for signal-ignoring openers.
unsubscribe during an in-flight subscribe'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/call is no longer abandoned to its generic timeout when
the socket drops unexpectedly (ADR-0021, issue #39). Server-side, the durable
commit + broadcast run before the committed send, 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/call and REPLAYS it on reconnect; the server's dedup table answers the
replayed txId with its true recorded outcome — a committed write resolves committed (never a timeout rollback) within the dedup window, a rejected one
rejects with its real MutationRejectedError. Where no reconnect can resolve it
(a terminal 4xxx close, or no active subscriptions), or the replay does not
answer within timeoutMs of the drop, the mutation settles promptly with the
new typed ConnectionLostError instead of the generic timeout. Timeout
semantics for a socket that stays open are unchanged.
Docs audit against @tanstack/db 0.9 / @tanstack/react-db 0.4. Every
README and recipe snippet now typechecks. The zod recipe imported defineSync
from tanstack-durable-object-sync/server, a subpath the package does not
export; it now imports from the package root. Corrected claims: an authorize
throw surfaces its reason without the VALIDATION code (only schema failures
carry it); without a range index an on-demand window can't page lazily;
on-demand pages can include every row tied at the boundary, and an unlimited
query loads every matching row; the static where filter is eager-mode only; HydrationBoundary needs a DbProvider; and the mixin also adds the public readSyncSnapshot.
Shipped source maps now resolve: the package includes src/, which every .js.map/.d.ts.map references, so go-to-definition lands on the source
instead of a missing file.
A mut op with an empty key ("") is now rejected (ADR-0025). The pk is
client-supplied TEXT, so "" is never a real row identity. ADR-0014 said the
wire layer already refused it, but it did not. The server now answers with rejected (code: "VALIDATION") before authorize or any row write, so the client
gets a prompt MutationRejectedError and rolls back. Behaviour change: a
write that used "" as a pk used to be accepted and is now refused.
A replayed mut is no longer rejected by a lowered op limit (ADR-0025
amendment).maxOpsPerMutation was checked before the dedup lookup. If the
limit was lowered (for example, by a deploy) while a hold-and-replay
(ADR-0021) was pending, the replayed frame got LIMIT_EXCEEDED and the client
rolled back a write that had committed. The check now runs after the lookup,
so a resent mut gets its stored outcome while that receipt is retained.
A mut/call replayed while the original is still running no longer runs
twice (ADR-0025 amendment).authorize, and a command's execute, can
await I/O, and a second frame for the same txId could slip in and run its
own attempt. Under ADR-0021's hold-and-replay (the socket drops mid-authorize,
the client replays on reconnect), the replayed mutation failed on its own pk
and the client rolled back a write that had committed. A replayed command ran
its side effect a second time. A duplicate now waits for the running attempt
and replays its outcome. A replayed committed also flushes the socket's
pending deltas first, so it can't overtake a buffered delta (ADR-0002 C1).
Internal
CI tests both ends of the @tanstack/db peer range (ADR-0022): a locked
leg (the lockfile's version; the devDependency moves to ^0.9.2) and a floor leg that installs the version derived from peerDependencies.
A pack smoke test (npm run smoke:pack, run in CI) installs the packed
tarball into a throwaway consumer at the floor and locked versions, checks
the tarball ships everything its manifest and source maps reference,
type-checks the shipped declarations with skipLibCheck off, and boots a
consumer Worker in workerd that renders SSR from a Durable Object.
A weekly (and manually dispatchable) workflow runs the suite and the pack
smoke test against @tanstack/db@latest.