Skip to content

feat: SSR support (experimental) — tracks TanStack DB draft PR #1564#2

Open
grrowl wants to merge 5 commits into
mainfrom
feat/ssr
Open

feat: SSR support (experimental) — tracks TanStack DB draft PR #1564#2
grrowl wants to merge 5 commits into
mainfrom
feat/ssr

Conversation

@grrowl

@grrowl grrowl commented Jun 10, 2026

Copy link
Copy Markdown
Owner

SSR support (experimental) — dehydrate on the worker, hydrate to the cursor

Implements SSR for this adapter against TanStack DB draft PR #1564 (DbClient, dehydrate()/hydrate(), the exportSyncMeta/importSyncMeta/mergeSyncMeta sync-config hooks). Design and every trade-off: ADR-0011.

What lands

  • Server: readSyncSnapshot(req, request) RPC — 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; "0" honestly means "no resume point".
  • Client: SsrSnapshotTransport (same adapter, swapped at the new structural Transport seam; read-only, fails loud), syncMeta {v, cursor, where-fingerprint} round-trip, since on first sub, seedCursor (late chunks regress-and-replay via 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); sub accepts since on first subscribe.
  • Three pre-existing bug fixes (not SSR-specific — see merge policy): C1′ cursor barrier, held-key catch-up upsert, reconnecting-at-scheduling.
  • examples/ssr: TanStack Start on Cloudflare — /live-query + /live-suspense-query (a hydrated collection never suspends; rows are in the server HTML inside a completed boundary).

Hardened by two adversarial reviews (gpt-5.5) + a full grill session — five of their findings were real bugs, all fixed with pinned tests. 169 tests green against the vendored PR build (zero breakage from released 0.6.5 → PR 0.6.7).

Vendored upstream builds — provenance & rebase policy

vendor/*.tgz are built from upstream PR head 132d53a9f03e9d0df442b2d15c74e5931925b77b (2026-05-30) — full provenance + single-copy-resolution gotcha in vendor/README.md. Policy:

  • When the upstream PR is revised: rebuild the tarballs, update the provenance table, re-run the suite, rebase this branch with the updated commits. Green tests against stale tarballs prove nothing.
  • When upstream ships (canary/release): delete vendor/, move to the published version, and rebase the tarball commits out of history before merging.

Merge policy

  • Do not merge until upstream ships. This branch tracks an unreviewed draft; the hook signatures may change (the adapter surface is deliberately one closure + three small hook bodies).
  • ✅ The three standalone reconnect fixes go to main separately (fix/reconnect-hardening → 0.3.1) — plan in .claude/scratch/reconnect-hardening-followup.md.

Known limitations (documented in ADR-0011)

No incarnation epoch (protocol-rev material, deferred until client-side persistence makes old cursors routine); below-retention-floor hydration flashes (pathological: requires retention shorter than HTML flight time); pre-1.0 client/server version skew unsupported.

🤖 Generated with Claude Code

@grrowl

grrowl commented Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Adapter-author field report: implementing the PR #1564 SSR contract in a sync backend

Findings from building full SSR support for tanstack-do-db-collection (a Cloudflare Durable Object sync backend) against TanStack/db PR #1564 at 132d53a9. We believe this is the first non-trivial sync adapter implemented against the draft contract; everything below was verified in code and pinned by tests, not read from docs (there are none for adapter authors yet). Intended as a formal report to carry upstream when we engage on the PR.

What works well

  • The syncMeta hooks are the right altitude. An opaque exportSyncMeta/importSyncMeta/mergeSyncMeta channel is exactly where a resume cursor rides. Our entire adapter surface for SSR is one closure and three small hook bodies.
  • Hydration as synced (not optimistic) upserts composes correctly with commit-boundary cursor models.
  • Hydration NOT marking ready is correct — readiness must stay the adapter's call — but it is currently undocumented and at least one of us "knew" the opposite until tests said otherwise.
  • Running the real sync function server-side (the start-ssr-e2e fixture pattern) works, including on-demand loadSubset under preload().

Findings (ordered by how hard they bit)

1. Rows are applied BEFORE importSyncMeta, and there is no veto — every adapter needs a fail-safe path

hydrate()/applyRows commits the chunk's rows, then consults the adapter (client.ts: rows → mergeSyncMetaimportSyncMeta). An adapter that throws on unrecognizable meta (future version, corruption) cannot undo the rows — so a naive fail-loud leaves applied rows with no resume bookkeeping, and a row deleted server-side meanwhile is stale forever. We had to make both hooks set a safe "no resume point → snapshot-reconcile" state before throwing. Suggest: document the no-veto ordering loudly, or give adapters a pre-apply hook / a way to reject a chunk.

2. mergeSyncMeta throwing skips importSyncMeta entirely

Because merge runs first, a throw there bypasses the import hook — an adapter that put its fail-safe only in importSyncMeta (our first attempt) is silently unprotected on the streamed-chunk path. Same suggestion as (1); at minimum the ordering contract deserves a sentence in the hook JSDoc.

3. DuplicateKeySyncError on insert-over-existing is the adapter landmine

A post-hydration snapshot/catch-up routinely re-delivers keys the client already holds with changed values — insert then throws (only deep-equal echoes are converted to update). Every SSR adapter will need "held-key insert ⇒ update" normalization; ours also needed it for a pre-existing delete-then-reinsert reconnect case. Worth documenting as THE expected adapter pattern, or relaxing sync insert to upsert semantics.

4. Dehydrated state has no tombstone concept

DehydratedCollectionRow can add/overwrite, never remove — a streamed applyCollectionChunk cannot express a delete. Fine for snapshot-at-cursor semantics, but it means all delete-correctness lands on the adapter's post-connect catch-up, and an adapter without a resumable change log has no sound way to remove a hydrated row that died in flight. Deserves explicit documentation (and a decision on whether streaming chunks should carry deletes before 1.0).

5. The hooks are per-config with no per-instance identity

dehydrate() calls collection.config.sync.exportSyncMeta?.() — no collection/session argument. With the documented pattern of module-scope options + per-request server DbClients, any adapter keeping cursor state where the hooks can reach it (the options closure) leaks state across concurrent requests. We sidestep it only because our options bake in a per-request transport. Suggest: pass the collection (or a session token) to the hooks, mirroring how sync() receives {collection, ...}.

6. Hydration + useLiveSuspenseQuery works beautifully — and nobody says so

A hydrated collection whose adapter marks ready synchronously never suspends: rows render inside a completed boundary server-side, fallback count stays 0 through hydration and query-identity changes. This is the SSR payoff and would make a great docs example.

7. Adapter-author documentation gap

docs/guides/collection-options-creator.md got only cosmetic updates in the PR. Items 1–5 above are, in effect, the missing contract documentation — we'd be glad to contribute a draft.

What we built on top (for context)

Cursor in syncMeta ({v, cursor, where-fingerprint}), durable high-water cursor from the DO, since on first subscribe with server catch-up, authoritative-set snapshot reconciliation (no truncate flash), self-healing min-merge for late/streamed chunks. Details: ADR-0011.

🤖 Generated with Claude Code

grrowl and others added 5 commits July 1, 2026 14:56
…ndency)

SSR (ADR-0011) tracks TanStack DB draft PR #1564, whose dehydrate/hydrate/
DbClient and syncMeta hooks are upstream and unreleased. Tests build against a
packed PR-branch tarball; removed when upstream publishes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNF6dsTLXtUxHJ6rEE33po
…R-0011 D1)

One consistent {rows, cursor} read over the DO binding, no WebSocket. The
required request runs through parseAttachment — the same auth gate as the WS
upgrade, so one tenant check guards both paths (test: a 'forbidden' identity
rejects the read). 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNF6dsTLXtUxHJ6rEE33po
…, syncMeta hooks (ADR-0011)

Transport becomes a generic + branded structural interface Transport<Api>;
WebSocketTransport<Api> and the new SsrSnapshotTransport<Api> both satisfy it
and carry Api, so doCollectionOptions inference survives the seam. seedCursor
claims a shorter applied prefix (SSR hydration) — a live regress forces a
reconnect (advance suppressed) so replay owns the repair window. The eager path
always arms snapshot reconcile (authoritative set semantics, no flash-to-empty);
the syncMeta hooks (export/import/merge) fail loud but SAFE — hydratedCursor='0'
before throwing so applied rows still reconcile. Test mocks that hit the eager
reconcile carry _state.syncedData.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNF6dsTLXtUxHJ6rEE33po
One todos collection in a DO, server-rendered two ways (useLiveQuery /
useLiveSuspenseQuery). Authored with the object-schema API (defineSync); the
browser brands its transport with the exported TodosApi. Loader reads one
snapshot and dehydrates; the browser hydrates, paints, and converges live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNF6dsTLXtUxHJ6rEE33po
Generalizes ADR-0002 C1 (deltas flush before committed) to C1' (a socket's
pending coalesced deltas precede any cursor boundary). Marks SSR experimental in
the changelog under [Unreleased].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNF6dsTLXtUxHJ6rEE33po
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant