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
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,31 @@ While pre-1.0, the public API may change between 0.x releases.

## [Unreleased]

_Nothing yet._
### Changed

- **Authoring is now an object schema, not a builder (ADR-0014).**
`new SyncRegistry().defineCollection().defineMutation().defineCommand()` is
replaced by `defineSync<User, Env>()`, which binds identity/env once and
returns `{ collection, command, schema }`. Mutations are a **closed
insert/update/delete trio** co-located on the collection (mirroring
`@tanstack/db`'s `onInsert/onUpdate/onDelete` — a custom mutation type is now
structurally unrepresentable), superseding ADR-0001 D11 and absorbing
ADR-0010's row manifest: the row type lives on the collection
(`sync.collection<Message>({ pk })`) instead of a third `SyncRegistry`
generic. The DO registers the schema value with `this.registerSync(schema)`.
Breaking, no compat shim.

### Added

- **Typed commands, end to end (ADR-0014).** `export type Api = typeof schema`
is the whole client contract: `new WebSocketTransport<Api>()` exposes a typed
`transport.call.<command>(args)` proxy plus a typed low-level
`sendCall(name, args)` (txId generated internally via `crypto.randomUUID()`),
and `doCollectionOptions<Api, "table">({ … })` infers the row type from the
schema — one source of truth across server and client.
- **`examples/multi-do`** — a two-Durable-Object example: one transport per DO, a
React `SyncProvider`/`useSync` keyed by DO so command namespaces never
collide, and a client-side cross-DO feed.

## [0.3.3] — 2026-06-13

Expand Down
134 changes: 100 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,74 @@ the milestone sequence.
### 1. Define your Durable Object

```ts
import { SyncRegistry, SyncDurableObject } from "tanstack-do-db-collection"
import { defineSync, SyncDurableObject } from "tanstack-do-db-collection"

interface Claims { userId: string }
interface Env { /* your bindings */ }
interface Message { id: string; author: string; content: string; created_at: number }

// defineSync binds identity (Claims) and binding-env (Env) once and returns
// three co-located helpers. They flow `user`/`env` into every handler ctx.
const sync = defineSync<Claims, Env>()

// The schema VALUE is both the DO registration and the client contract. The
// collection KEY ("messages") is the DB table name. `pk` must be a real column
// of Row — the sole TEXT, client-supplied key (ADR-0007).
export const chatSchema = sync.schema({
collections: {
messages: sync.collection<Message>({
pk: "id",
// The closed mutation trio { insert?; update?; delete? } — a 4th key is a
// type error. op.cols is typed per op: full Row on insert, Partial on
// update, absent on delete.
mutations: {
insert: {
// authorize runs BEFORE the tx (async ok); throw to deny.
// op.cols is typed Message here — no cast.
authorize: ({ user, op }) => {
if (op.cols.author !== user.userId) {
throw new Error("author mismatch")
}
},
// execute runs INSIDE transactionSync — synchronous only.
execute: ({ op, sql }) => {
const m = op.cols // Message
sql.exec(
"INSERT INTO messages(id, author, content, created_at) VALUES (?, ?, ?, ?)",
m.id, m.author, m.content, m.created_at,
)
},
// afterCommit (optional): fire-and-forget AFTER the commit + receipt —
// the home for external side effects execute can't do (delete an R2
// object, enqueue a job). Receives `env`; owns its own idempotency.
// afterCommit: async ({ op, env }) => { await env.BUCKET.delete(op.key) },
},
delete: {
execute: ({ op, sql }) => {
// delete carries op.key only — no op.cols.
sql.exec("DELETE FROM messages WHERE id = ?", op.key)
},
},
},
}),
},
// Commands are the escape hatch for writes that aren't a single typed row op.
// Their own SQL still flows through the CDC triggers, and they can return a
// result. Type-only Args is curried (call the factory twice); Result is
// inferred from the return. If you have commands you MUST declare them inline
// here so Args/Result inference flows into the Api type.
commands: {
clearRoom: sync.command()(({ sql }) => {
const before = Array.from(sql.exec("SELECT count(*) AS c FROM messages"))[0]!.c as number
sql.exec("DELETE FROM messages")
return { deleted: before }
}),
},
})

// Export the schema type as the client contract.
export type Api = typeof chatSchema

export class SessionDO extends SyncDurableObject<Env, Claims> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
Expand All @@ -101,35 +164,9 @@ export class SessionDO extends SyncDurableObject<Env, Claims> {
created_at INTEGER NOT NULL
)`)

this.registerSync(
// The third generic is the collection manifest: table → row type. It
// types `pk` (must be a column) and every handler's `op` — no casts.
new SyncRegistry<Claims, Env, { messages: Message }>()
.defineCollection({ table: "messages", pk: "id" })
.defineMutation({
collection: "messages",
type: "insert",
// authorize runs BEFORE the tx (async ok); throw to deny.
// op.cols is typed Message here — no cast.
authorize: ({ user, op }) => {
if (op.cols.author !== user.userId) {
throw new Error("author mismatch")
}
},
// execute runs INSIDE transactionSync — synchronous only.
execute: ({ op, sql }) => {
const m = op.cols // Message
sql.exec(
"INSERT INTO messages(id, author, content, created_at) VALUES (?, ?, ?, ?)",
m.id, m.author, m.content, m.created_at,
)
},
// afterCommit (optional): fire-and-forget AFTER the commit + receipt —
// the home for external side effects execute can't do (delete an R2
// object, enqueue a job). Receives `env`; owns its own idempotency.
// afterCommit: async ({ op, env }) => { await env.BUCKET.delete(op.key) },
}),
)
// registerSync takes the schema VALUE — it compiles it, validates pk
// affinity, and wires the CDC triggers.
this.registerSync(chatSchema)
})
}

Expand Down Expand Up @@ -187,24 +224,34 @@ import { createCollection } from "@tanstack/db"
import { useLiveQuery } from "@tanstack/react-db"
import { doCollectionOptions, WebSocketTransport } from "tanstack-do-db-collection/client"
import { ulid } from "ulid"
import type { Api } from "./session-do" // TYPE-ONLY — nothing server-side is bundled

const transport = new WebSocketTransport<Api>({ url: `wss://${host}/sync/${sessionId}` })

const transport = new WebSocketTransport({ url: `wss://${host}/sync/${sessionId}` })
// Api-driven: the row type is inferred from the schema Api + table name, so
// there's no runtime schema value and no explicit Row generic. `m` is Message.
const messages = createCollection(
doCollectionOptions<Message>({ transport, table: "messages", getKey: (m) => m.id }),
doCollectionOptions<Api, "messages">({ transport, table: "messages", getKey: (m) => m.id }),
)

function ChatRoom({ userId }: { userId: string }) {
const { data } = useLiveQuery((q) => q.from({ m: messages }).orderBy(({ m }) => m.created_at, "asc"))
const send = (content: string) =>
// Optimistic; resolves once the server confirms on the single stream.
messages.insert({ id: ulid(), author: userId, content, created_at: Date.now() })
return <ChatView rows={data} onSend={send} />
// Commands run over the transport (not the collection). `call` is a typed
// Proxy — the name autocompletes and the result is checked against the Api.
const clear = () => transport.call.clearRoom() // Promise<{ deleted: number }>
return <ChatView rows={data} onSend={send} onClear={clear} />
}
```

One `WebSocketTransport` per DO is shared by every collection on that DO
(multiplexed over the single socket). Pass `where` to
`doCollectionOptions` to sync only a matching subset.
`doCollectionOptions` to sync only a matching subset. Commands go through the
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`.

---

Expand All @@ -223,6 +270,10 @@ browser-verified.
scroll-back, and a mutable order key so voting bumps a task to the top
(move-in). Its firehose makes the deferred bounded-window-under-churn
limitation visible — `loaded` climbs past `window`.
- **[`examples/multi-do`](./examples/multi-do)** — two separate DOs (a room and
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).

> [!TIP]
> Using on-demand with `orderBy` + `limit`? Add a **range index** on the order
Expand All @@ -232,6 +283,21 @@ browser-verified.

---

## Common patterns and recipes

Task-oriented guides in [`recipes/`](./recipes):

- **[Commands vs mutations](./recipes/commands-vs-mutations.md)** — when a write
is a typed `insert`/`update`/`delete` and when it's a named command.
- **[End-to-end types](./recipes/end-to-end-types.md)** — share one schema type
between server and client so the transport, commands, and collections are typed.
- **[On-demand and windows](./recipes/on-demand-and-windows.md)** — sync only the
rows a query asks for, and grow a bounded window as the user scrolls.
- **[Server-originated writes](./recipes/server-originated-writes.md)** — write
rows from the DO itself (webhooks, jobs, seeds) so clients still see them.

---

## Non-goals

- **Multi-DO transactions.** A transaction touches collections in one DO.
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0004-after-commit-hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The two obvious homes are both wrong:
there cannot be awaited, holds the write path, and is not atomic with the
external system — the transaction can roll back but R2 cannot.

A `command` (`defineCommand`) *can* do async work in `execute` (it runs outside
A `command` *can* do async work in `execute` (it runs outside
any transaction), and remains the right tool for RPC-shaped operations. But it
is not the collection's optimistic mutation path, and its only durability is
client-retry-driven dedup. We still want post-work attached to ordinary
Expand Down
Loading