feat: SSR support — lifted onto merged @tanstack/db 0.8.5 (ADR-0011) - #36
Merged
Conversation
…R-0011 D1)
Ported from feat/ssr-rebased onto the ADR-0015 mixin era, against the now-merged
upstream SSR API (@tanstack/db 0.8.5, PR #1564 shipped in 0.8.0).
One consistent {rows, cursor} read over the DO binding, no WebSocket. The
required request runs through the configured parseAttachment — the same auth
gate as the WS upgrade, so one tenant check guards both paths. The cursor is a
durable high-water mark (highWaterSeq = max(currentSeq, drainCursor)), robust
to retention pruning the changelog empty; '0' honestly means no resume point.
A catch-up terminal is scoped to its sub (uptodate.sub) so a transient
hydration sub tears down on its own boundary.
New since the original port: readSyncSnapshot is a public method on the mixin
(DO RPC dispatches only on public members — one deliberate addition to
ADR-0015's collision surface), ORDER BY rowid now comes free from
compileSubsetQuery, and RPC rows normalize BLOB ArrayBuffer→Uint8Array for
parity with the wire codec (ADR-0017 landed after the original branch).
Deps: @tanstack/db 0.6.5 → 0.8.5 (peer >=0.8.5); the full pre-existing suite
passes unchanged on 0.8.5.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…scoped terminals (ADR-0011 D3) Ported from feat/ssr-rebased and re-derived against the ADR-0016 reconnect policy machinery. The structural Transport<Api> seam lands (WebSocketTransport and the SSR snapshot transport are interchangeable, Api-branded so inference survives); subscribe takes a one-shot since; a sub-scoped uptodate dispatches to its own handler alone (ownTerminal). seedCursor claims a shorter applied prefix on behalf of hydrated state; a live regress forces a reconnect so the fresh socket's replay owns the repair window. Two deliberate departures from the original branch: - forceReconnect is voluntary — it bypasses the backoff policy (no attempt consumed, no delay, a custom policy can't declare it terminal) but still runs the resubscribe path; a failed open falls back into the policy-driven retry. The original predates ADR-0016 and reused the fixed-delay scheduler. - suppressAdvance is replaced by a socket-identity guard on message dispatch: only the current socket speaks for the stream. The flag protected the cursor but still let an abandoned socket's queued frames dispatch data, and its reset-at-install left a race window; dropping stale frames entirely is strictly safer — the resubscribe catch-up re-covers them idempotently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oks, hydrated sync paths (ADR-0011)
Ported from feat/ssr-rebased and adapted to the MERGED upstream SSR API
(@tanstack/db 0.8.5; PR #1564 shipped in 0.8.0 with reshaped semantics the
draft did not have):
- exportSyncMeta returns UNDEFINED when the adapter holds no claim. Merged
upstream asks exportSyncMeta() for the CURRENT meta on every hydrated chunk
and MIN-merges it with the incoming one; a fresh browser transport exporting
{cursor:"0"} would win that merge against every real dehydrated cursor and
silently downgrade all hydration to snapshot-reconcile. A new hasPosition on
the Transport seam keeps "0" a REAL claim where it is one (an SSR read
against a DO with no history → the honest-truncate route) and distinguishes
it from no-claim-at-all.
- commit() receipts (0.8.5): flush() propagates SyncAppliedReceipt; snapshot
terminals and cursor load-more settle loadSubset only once rows are visible.
- markError (0.8.2): a failed ready-gate/first subscribe fails preload() loud
with the cause instead of hanging; the policy-driven reconnect plus a
retried preload() recovers (error → ready).
- withCollectionConfigFactory (0.8.0): doCollectionOptions configs are usable
as collectionOptions(id, …) descriptors, materializing FRESH adapter state
per DbClient; the transport stays the caller's (per-request for SSR).
- On-demand subset load failures reject that subset's load promise (0.8.4
loadSubset:error), not the whole collection.
SsrSnapshotTransport, the syncMeta cursor round-trip (v/cursor/where
fingerprint, fail-loud-but-safe import/merge), the transient hydration
catch-up sub with sub-scoped terminal, and always-armed eager snapshot
reconcile port unchanged. End-to-end tests now run the RELEASED DbClient on
both sides (server render → dehydrate → hydrate → converge). 265 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hangelog ADR-0011 lands in its reserved slot with the original design intact plus an Amendments section recording every deliberate departure the lift made: export-undefined/hasPosition (merged applyRows consults exportSyncMeta as current meta), readSyncSnapshot on the Syncable mixin (+1 to ADR-0015's collision surface), the ADR-0016-aware voluntary forceReconnect and the socket-identity guard replacing suppressAdvance, the adopted 0.8.x contracts (receipts, markError, subset-load rejection, config factory), and what was deliberately NOT lifted (the upstream live-query layer needs nothing from the adapter). README documents the released-API usage (descriptor form, HydrationBoundary pointer). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ch pinned Adversarial review (gpt-5.6-sol) of the lift diff surfaced seven real bugs; all fixed with tests that fail without the fix: - parseSyncMeta rejects a NEGATIVE cursor: it would ride since to the server, draw a full snapshot the on-demand catch-up handler discards, and leak the transient unfiltered sub (and its stale rows) forever. The fail-loud-but- safe contract routes it to the honest-truncate path instead. - mergeSyncMeta with mismatched where fingerprints yields cursor '0' under OUR fingerprint: MIN alone could return the side matching our filter while the other side's foreign-filter rows were already applied — import would accept a cursor whose catch-up never covers them. - Stale-socket dispatch is receipt-aware: committed/rejected/page settle their waiters from an abandoned socket (no replay re-covers them; a committed mutation must not become a timeout because a late hydration chunk forced a reconnect) — but never advance the cursor; stream frames stay dropped and replayed. - exportSyncMeta is settlement-gated: while any commit receipt is unsettled it claims the last fully-settled position, never the boundary cursor — a dehydrate in that window would otherwise serialize pre-boundary rows under meta claiming the boundary, skipping the gap forever on resume. - A REJECTED SyncAppliedReceipt is failure, not success: afterApplied routes it to the subset load's rejection (loadSubset:error) or markError, instead of resolving readiness over rows that never became visible. - The on-demand error state heals: the catch-up terminal/reset and every completed subset load also markReady() (idempotent), so a failed first open recovers to ready once the policy-driven reconnect succeeds. - unsubscribe during an in-flight subscribe's connect no longer sends a ghost sub the server would persist (ADR-0019) with no local consumer — pre-existing on main, surfaced by this review. 271 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ient
The draft-era example rebuilt against released packages (@tanstack/db 0.8.5,
react-db 0.3.5, and the new @tanstack/react-router-with-db 0.1.0). The
hand-rolled round trip is gone: no createServerFn snapshot endpoint, no manual
db.hydrate() layout, no inert SSR transport — routerWithDbClient does the
dehydrate/hydrate/stream plumbing, and the app-specific part shrinks to one
seam: a DbClient dependency providing the environment's transport
(SsrSnapshotTransport over readSyncSnapshot RPC on the server — same
claims-bearing Request as the WS upgrade, one auth gate — WebSocketTransport
in the browser), consumed by a shared collectionOptions('todos', factory)
descriptor with zero casts.
Two routes: /live-query (loader preload — rows in the server HTML, the socket
resumes from the dehydrated cursor and converges) and /live-suspense-query
(no preload — the query suspends server-side and its result streams into the
document; toggling the filter demonstrates IR-derived query identity).
Verified: npm install clean, tsc --noEmit zero errors, vite build green for
both client and ssr environments; worker-only modules confirmed absent from
the client bundle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 26, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SSR support — dehydrate on the worker, hydrate to the cursor
Upstream shipped: TanStack DB's SSR support merged via PR #1564 and released in
@tanstack/db0.8.0 (DbClientdehydrate()/hydrate(), theexportSyncMeta/importSyncMeta/mergeSyncMetasync-config hooks — byte-for-byte the signatures our draft-era branch implemented). This PR is the full lift of that work onto released 0.8.5: no vendored tarballs, currentmain(v0.6.0, ADR-0015–0019 era), and the semantics the merged API actually has. Design + every departure: ADR-0011 (original decision record intact, with a lift Amendments section).Supersedes #2 (
feat/ssr, which tracks the dead draft + vendored builds). Replay source was the completedfeat/ssr-rebasedport, re-derived commit-by-commit against current main.What lands
readSyncSnapshot(req, request)RPC on theSyncablemixin — one consistent{rows, cursor}read over the DO binding, no WebSocket. The requiredrequestruns throughparseAttachment: one auth gate for the socket and the read path. Cursor is a durable high-water mark (max(currentSeq, drainCursor));"0"honestly means "no resume point". BLOB values normalizeArrayBuffer → Uint8Arrayfor wire-codec parity (ADR-0017).SsrSnapshotTransport(read-only, per-request, swapped at the new structuralTransport<Api>seam), syncMeta{v, cursor, where-fingerprint}round-trip with fail-loud-but-safe import/merge,sinceon first sub,seedCursor(late chunks regress-and-replay 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.uptodategains optionalsub(a catch-up's terminal is sub-scoped).exportSyncMetareturnsundefinedwhen the adapter holds no claim, via a newTransport.hasPositionseam. Merged upstream consultsexportSyncMeta()as "current" on every hydrated chunk; a fresh browser transport exporting{cursor:"0"}would win the MIN-merge against every real dehydrated cursor and silently kill cursor resume. An SSR read at cursor 0 stays a real "no resume point" claim.commit()receipts (SyncAppliedReceipt— subset loads settle only once rows are visible),markError(failed first connect failspreload()loud; retried preload recovers), per-subset load rejection, andwithCollectionConfigFactory(configs work ascollectionOptions(id, …)descriptors with fresh adapter state perDbClient).suppressAdvanceflag is replaced by receipt-aware socket-identity dispatch (stream frames from an abandoned socket drop and replay;committed/rejected/pagestill settle their waiters, never the cursor).examples/ssr: rebuilt on TanStack Start + the new@tanstack/react-router-with-db— the hand-rolled serverFn/hydrate plumbing is gone; the app-specific part is one transport dependency on theDbClient(snapshot read on the server, WebSocket in the browser) consumed by a shared typed descriptor, zero casts. Two routes: loader-preloaded (rows in the server HTML, socket resumes from the dehydrated cursor) and Suspense-streamed.Adversarial review
A codex (gpt-5.6-sol) pass over the lift diff found seven real bugs — all fixed, each pinned by a test (commit
227c2aa): negative-cursor validation (would leak the on-demand catch-up sub forever), fingerprint-awaremergeSyncMeta(MIN alone could smuggle foreign-filter rows past import), receipt-aware stale-socket dispatch (a committed mutation must not become a timeout), settlement-gatedexportSyncMeta(never claim a boundary whose receipt is unsettled), rejected-receipt-is-failure, on-demand error→ready healing, and a pre-existing ghost-subscription fix (unsubscribeduring an in-flight connect).Verification
DbClienton both sides. The full pre-lift suite passes on 0.8.5 unchanged.tsctypecheck + package build green; example installs, typechecks, and builds for both client and ssr environments (worker-only modules verified absent from the client bundle).Notes for review
>=0.6.0→>=0.8.5(the hooks shipped in 0.8.0; 0.8.5 carries the receipt contract + descriptor reuse we adopt).LoadSubsetOptions.signal(cooperative cancellation of shared refcounted subs).🤖 Generated with Claude Code