Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,57 @@ While pre-1.0, the public API may change between 0.x releases.

## [Unreleased]

### Added

- **SSR support (experimental, ADR-0011)** — built on TanStack DB's merged
SSR API (`DbClient` `dehydrate()`/`hydrate()`, PR
[#1564](https://github.com/TanStack/db/pull/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.5`** (was `>= 0.6.0`) — the SSR
hooks shipped in 0.8.0; 0.8.5 carries the `commit()`-receipt contract and
descriptor reuse this adapter adopts. The 0.6-era API is otherwise
unchanged: the full pre-lift suite passes 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. ID-scoped receipts (`committed`/`rejected`/`page`) still
settle their waiters from a stale socket — they are not re-covered by any
replay — but never advance the cursor.

### Fixed

- `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.

## [0.6.0] — 2026-07-27

### Added
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,57 @@ same socket: `transport.call.<name>(args)` (typed sugar) or the low-level
`transport.sendCall("clearRoom", undefined)` — both mint the txId for you and
resolve with the command's result on `committed`.

### 4. SSR (experimental)

Built on TanStack DB's SSR support (`DbClient` `dehydrate()`/`hydrate()` and
the `exportSyncMeta`/`importSyncMeta`/`mergeSyncMeta` sync hooks, shipped in
`@tanstack/db` 0.8.0 — this adapter requires ≥ 0.8.5). Why/how trade-offs
live in [ADR-0011](./docs/adr/0011-ssr-dehydrate-hydrate.md).

On the worker, render through a **per-request** `DbClient` backed by one
snapshot read per subscription — no WebSocket from the render path:

```ts
// Route loader / server handler (per request!)
import { DbClient, collectionOptions } from "@tanstack/db"
import { doCollectionOptions, SsrSnapshotTransport } from "tanstack-durable-object-sync/client"

const stub = env.CHAT_DO.get(env.CHAT_DO.idFromName(sessionId))
// `request` is the incoming (claims-bearing) Request — the DO runs it through
// parseAttachment, the SAME auth gate as the WebSocket upgrade.
const transport = new SsrSnapshotTransport<Api>({
read: (req) => stub.readSyncSnapshot(req, request),
})
const db = new DbClient()
const messages = db.collection(
collectionOptions("messages", () =>
doCollectionOptions<Api, "messages">({ transport, table: "messages", getKey: (m) => m.id }),
),
)
await messages.preload()
return { dbState: db.dehydrate() } // rows + our resume cursor (opaque syncMeta)
```

In the browser, hydrate before going live. The collection is ready
immediately with the dehydrated rows (stale-while-revalidate); the first sub
resumes from the dehydrated cursor, so the catch-up applies exactly what
changed while the HTML was in flight — updates *and* deletes:

```ts
const db = new DbClient()
db.hydrate(dbState) // or <HydrationBoundary state={dbState}> from @tanstack/react-db
const messages = db.collection(
collectionOptions("messages", () =>
doCollectionOptions<Api, "messages">({ transport: wsTransport, table: "messages", getKey: (m) => m.id }),
),
)
```

Mutations during SSR throw (`SsrReadOnlyError`). `readSyncSnapshot` is callable
by any worker holding the DO binding, and its required `request` argument runs
through `parseAttachment` — **one auth gate for both the socket and the read
path**, so a tenant check in `parseAttachment` can't be bypassed by SSR.

---

## Examples
Expand All @@ -288,6 +339,10 @@ browser-verified.
an inbox) behind one Worker: one transport per DO, each typed by its own `Api`
so `transport.call.*` is scoped to that DO's commands, and a cross-DO feed
merged client-side (the DO never joins — ADR-0001).
- **[`examples/ssr`](./examples/ssr)** — TanStack Start on Cloudflare with
`routerWithDbClient`: a loader-preloaded page (rows in the server HTML,
WebSocket resumes from the dehydrated cursor) and a Suspense-streaming page
(`useLiveSuspenseQuery`, result streamed into the document). ADR-0011.

> [!TIP]
> Using on-demand with `orderBy` + `limit`? Add a **range index** on the order
Expand Down
Loading