Skip to content

feat: SSR support — lifted onto merged @tanstack/db 0.8.5 (ADR-0011) - #36

Merged
grrowl merged 6 commits into
mainfrom
feat/ssr-lift
Aug 26, 2026
Merged

feat: SSR support — lifted onto merged @tanstack/db 0.8.5 (ADR-0011)#36
grrowl merged 6 commits into
mainfrom
feat/ssr-lift

Conversation

@grrowl

@grrowl grrowl commented Aug 26, 2026

Copy link
Copy Markdown
Owner

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/db 0.8.0 (DbClient dehydrate()/hydrate(), the exportSyncMeta/importSyncMeta/mergeSyncMeta sync-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, current main (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 completed feat/ssr-rebased port, re-derived commit-by-commit against current main.

What lands

  • Server: readSyncSnapshot(req, request) RPC on the Syncable mixin — 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. Cursor is a durable high-water mark (max(currentSeq, drainCursor)); "0" honestly means "no resume point". BLOB values normalize ArrayBuffer → Uint8Array for wire-codec parity (ADR-0017).
  • Client: SsrSnapshotTransport (read-only, per-request, swapped at the new structural Transport<Api> seam), syncMeta {v, cursor, where-fingerprint} round-trip with fail-loud-but-safe import/merge, since on 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.
  • Wire (additive): uptodate gains optional sub (a catch-up's terminal is sub-scoped).
  • Merged-API adaptations (the interesting deltas vs the draft — full rationale in the ADR Amendments):
    • exportSyncMeta returns undefined when the adapter holds no claim, via a new Transport.hasPosition seam. Merged upstream consults exportSyncMeta() 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.
    • Adopted 0.8.x contracts: commit() receipts (SyncAppliedReceipt — subset loads settle only once rows are visible), markError (failed first connect fails preload() loud; retried preload recovers), per-subset load rejection, and withCollectionConfigFactory (configs work as collectionOptions(id, …) descriptors with fresh adapter state per DbClient).
    • The regress-reconnect is re-derived against ADR-0016: it is voluntary — bypasses the backoff policy, consumes no attempt, cannot be declared terminal — and the draft's suppressAdvance flag is replaced by receipt-aware socket-identity dispatch (stream frames from an abandoned socket drop and replay; committed/rejected/page still 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 the DbClient (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-aware mergeSyncMeta (MIN alone could smuggle foreign-filter rows past import), receipt-aware stale-socket dispatch (a committed mutation must not become a timeout), settlement-gated exportSyncMeta (never claim a boundary whose receipt is unsettled), rejected-receipt-is-failure, on-demand error→ready healing, and a pre-existing ghost-subscription fix (unsubscribe during an in-flight connect).

Verification

  • 271 tests green (238 pre-lift + 33 SSR/hardening), including end-to-end dehydrate→hydrate→converge running the real released DbClient on both sides. The full pre-lift suite passes on 0.8.5 unchanged.
  • tsc typecheck + 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

  • Peer dependency moves >=0.6.0>=0.8.5 (the hooks shipped in 0.8.0; 0.8.5 carries the receipt contract + descriptor reuse we adopt).
  • Documented follow-up, deliberately not in scope: LoadSubsetOptions.signal (cooperative cancellation of shared refcounted subs).
  • Ecosystem context: only ElectricSQL implements the syncMeta hooks today — this is the second adapter with true resumable SSR hydration.
  • The example builds but has not had a browser pass yet.

🤖 Generated with Claude Code

grrowl and others added 6 commits August 26, 2026 11:53
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant