Skip to content

Commit 65adcba

Browse files
grrowlclaude
andcommitted
feat(server)!: typed mutations via collection manifest; rename Registry→SyncRegistry (ADR-0010)
SyncRegistry takes a third generic — a collection-row manifest (table → row type) — so mutation handlers are fully typed with no casts: new SyncRegistry<Claims, Env, { messages: Message }>() .defineCollection({ table: "messages", pk: "id" }) // pk ∈ keyof Message .defineMutation({ collection: "messages", type: "insert", execute: ({ op }) => op.cols.author }) // Message op.cols is discriminated per op (insert→Row, update→Partial<Row>, delete→none); pk and the collection name are checked against the manifest. Purely type-level — runtime dispatch is unchanged (the typed def is erased into the stored MutationDef at one isolated boundary), so the 139 runtime tests are untouched; correctness is pinned by type-level tests (tests/registry-types.ts: tsc expect-pass + @ts-expect-error). The manifest defaults so the untyped `new SyncRegistry<Claims>()` still compiles (op.cols falls back to unknown). The TS partial-inference wall (can't specify the row type while inferring the table literal) is why the type lives on the constructor, not the method — see ADR-0010. BREAKING: `Registry` is renamed `SyncRegistry` (no compat shim) — the old name was generic/clashy and the public surface is uniformly Sync* (SyncDurableObject, runSyncedWrite, registerSync). Update imports and `new SyncRegistry(...)`. Commands (defineCommand args) deferred to a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e4fa65b commit 65adcba

14 files changed

Lines changed: 325 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ While pre-1.0, the public API may change between 0.x releases.
1010

1111
### Added
1212

13+
- **Typed mutations** (ADR-0010). `SyncRegistry` takes a third generic — a
14+
collection-row manifest — so handlers are fully typed without casts:
15+
`new SyncRegistry<TUser, Env, { messages: Message }>()` types `pk` (must be a
16+
column) and each handler's `op.cols` per op (`insert``Message`,
17+
`update``Partial<Message>`, `delete`→none). Purely type-level; the untyped
18+
two-generic form still works (`op.cols` falls back to `unknown`).
1319
- **Changelog time-based retention** (ADR-0009). A new `changelogRetentionMs`
1420
knob (default 2 days) prunes `_sync_changes` rows older than the window, so the
1521
log is bounded by age, not just key-cardinality. A client reconnecting from
@@ -19,6 +25,10 @@ While pre-1.0, the public API may change between 0.x releases.
1925

2026
### Changed
2127

28+
- **Renamed `Registry``SyncRegistry`.** Breaking, no compat shim. The old
29+
name was too generic and clashy; the public surface is uniformly `Sync*`
30+
(`SyncDurableObject`, `runSyncedWrite`, `registerSync`). Update your import and
31+
`new SyncRegistry(...)`.
2232
- `registerSync` now reconciles CDC triggers to the registry instead of only
2333
adding them: triggers for a collection you've removed from the registry are
2434
dropped on the next `registerSync`, so an orphaned table stops firing capture

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ the milestone sequence.
8181
### 1. Define your Durable Object
8282

8383
```ts
84-
import { Registry, SyncDurableObject } from "tanstack-do-db-collection"
84+
import { SyncRegistry, SyncDurableObject } from "tanstack-do-db-collection"
8585

8686
interface Claims { userId: string }
8787
interface Message { id: string; author: string; content: string; created_at: number }
@@ -102,20 +102,23 @@ export class SessionDO extends SyncDurableObject<Env, Claims> {
102102
)`)
103103

104104
this.registerSync(
105-
new Registry<Claims>()
105+
// The third generic is the collection manifest: table → row type. It
106+
// types `pk` (must be a column) and every handler's `op` — no casts.
107+
new SyncRegistry<Claims, Env, { messages: Message }>()
106108
.defineCollection({ table: "messages", pk: "id" })
107109
.defineMutation({
108110
collection: "messages",
109111
type: "insert",
110112
// authorize runs BEFORE the tx (async ok); throw to deny.
113+
// op.cols is typed Message here — no cast.
111114
authorize: ({ user, op }) => {
112-
if ((op.cols as Message).author !== user.userId) {
115+
if (op.cols.author !== user.userId) {
113116
throw new Error("author mismatch")
114117
}
115118
},
116119
// execute runs INSIDE transactionSync — synchronous only.
117120
execute: ({ op, sql }) => {
118-
const m = op.cols as Message
121+
const m = op.cols // Message
119122
sql.exec(
120123
"INSERT INTO messages(id, author, content, created_at) VALUES (?, ?, ?, ?)",
121124
m.id, m.author, m.content, m.created_at,
@@ -124,7 +127,7 @@ export class SessionDO extends SyncDurableObject<Env, Claims> {
124127
// afterCommit (optional): fire-and-forget AFTER the commit + receipt —
125128
// the home for external side effects execute can't do (delete an R2
126129
// object, enqueue a job). Receives `env`; owns its own idempotency.
127-
// afterCommit: async ({ op, env }) => { await env.BUCKET.delete(op.key as string) },
130+
// afterCommit: async ({ op, env }) => { await env.BUCKET.delete(op.key) },
128131
}),
129132
)
130133
})
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# 0010 — Typed mutations via a collection-row manifest on `SyncRegistry`
2+
3+
**Status:** Accepted. Types the mutation-handler surface introduced by
4+
[ADR-0007](./0007-author-owned-schema-register-sync.md). Commands
5+
(`defineCommand`) are explicitly out of scope (a follow-up).
6+
7+
## Context
8+
9+
A mutation handler receives `op.cols` as `Record<string, unknown>` — the wire
10+
type (`MutOp`, `frames.ts`). So every handler casts: `op.cols as Message`. That
11+
is repetitive, and it is *a lie to the compiler* — an unchecked assertion with
12+
no runtime backing. We want `op` typed by the collection's row type **and** the
13+
op kind:
14+
15+
- `insert``cols: Row` (the full row, ADR-0001 D19)
16+
- `update``cols: Partial<Row>` (top-level patch, ADR-0002 C6)
17+
- `delete` → no `cols` (just `key`)
18+
19+
This is **purely a type-level concern**: runtime dispatch is string-keyed and
20+
untyped (`mutations.get(\`${collection}:${op.type}\`).execute({ op, … })`), and
21+
the row type has no runtime presence. So the change touches signatures only —
22+
zero runtime behaviour change — and is verified by type-level tests.
23+
24+
### The constraint that decided the shape
25+
26+
The natural idea — `.defineCollection<Message>({ table: "messages", pk: "id" })`,
27+
accumulating `{ messages: Message }` so mutations infer the row from the
28+
collection name — **cannot compile**, for two converging reasons (both verified
29+
against `tsc --strict`, see below):
30+
31+
1. **TypeScript has no partial type-argument inference**
32+
([microsoft/TypeScript#26242](https://github.com/microsoft/TypeScript/issues/26242)).
33+
The moment you write an explicit `<Message>`, TS stops inferring the other
34+
type params, so the table-name literal can't be captured.
35+
2. **The row type has no runtime witness** in `{ table, pk }` — nothing to infer
36+
it *from* — so it must be an explicit annotation somewhere; which trips (1).
37+
38+
A wall probe confirmed it: with `defineCollection<Row, Table extends string = string>`,
39+
calling `<Message>(…)` widens `Table` to `string`, yielding `Record<string, Message>`
40+
— collections become indistinguishable.
41+
42+
So the only question is **where the one explicit row annotation lives.** Three
43+
homes compile (all verified); a fourth (schema) was rejected.
44+
45+
## Options (all compile-tested)
46+
47+
- **A — per mutation:** `.defineMutation<Message>({ collection, type, execute })`,
48+
the arg a discriminated union keyed on `type` (so no second inference is
49+
needed; `cols` is precise per op). Clean single `<>`, co-located with the
50+
handler. **But:** the row type is repeated on every mutation, and nothing ties
51+
`collection: "messages"` to `Message` (same trust as `as`, just centralised).
52+
- **B1 — per collection (curry):** `.defineCollection<Message>()({ table, pk })`.
53+
Declared once, co-located, links collection→row, and enforces *define-before-
54+
mutate* at compile time. **But:** the `()()` curry on every collection.
55+
- **B2 — constructor manifest:** `new SyncRegistry<TUser, Env, { messages: Message }>()`,
56+
then plain `.defineCollection`/`.defineMutation` with everything inferred. **The
57+
only option that types *both* call sites**`pk` against `keyof Row` and the
58+
table/collection names against the manifest, *and* `op.cols` per op — with no
59+
curry. **Cost:** the manifest sits apart from the `.defineCollection` calls
60+
(TS checks they agree), and you specify `Env` to reach the third slot.
61+
- **B3 — schema-driven (rejected):** `.defineCollection({ table, pk, schema })`
62+
infers `Row` from a [Standard Schema](https://standardschema.dev/) value *and*
63+
validates inbound `cols` at runtime. Rejected: it adds no real **safety** — SQL
64+
injection is already prevented by parameterised binding (values are never
65+
interpolated; identifiers are validated at registration, ADR-0007), and
66+
malformed input is already rejectable in `authorize` — while taxing the
67+
**write hot path** with a per-mutation validator, against the light/streaming
68+
ethos. Input validation, where wanted, belongs in `authorize`.
69+
70+
## Decision
71+
72+
**B2 — the constructor manifest.**
73+
74+
```ts
75+
class SyncRegistry<TUser = unknown, Env = unknown,
76+
TCols extends Record<string, unknown> = Record<string, unknown>> {
77+
defineCollection<Name extends keyof TCols & string>(
78+
def: { table: Name; pk: PkOf<TCols[Name]> },
79+
): this
80+
defineMutation<Name extends keyof TCols & string, T extends RowOp>(
81+
def: {
82+
collection: Name
83+
type: T
84+
authorize?: (ctx: MutationCtx<TUser, Env, OpFor<T, TCols[Name]>>) => void | Promise<void>
85+
execute: (ctx: MutationCtx<TUser, Env, OpFor<T, TCols[Name]>>) => void
86+
afterCommit?: (ctx: MutationCtx<TUser, Env, OpFor<T, TCols[Name]>>) => unknown
87+
},
88+
): this
89+
}
90+
type OpFor<T extends RowOp, Row> =
91+
T extends "insert" ? { type: "insert"; key: string; cols: Row }
92+
: T extends "update" ? { type: "update"; key: string; cols: Partial<Row> }
93+
: { type: "delete"; key: string; cols?: undefined }
94+
// PkOf<Row> = [keyof Row] extends [never] ? string : keyof Row & string
95+
```
96+
97+
- **Non-breaking for the untyped path.** `TCols` defaults to
98+
`Record<string, unknown>`, so `new SyncRegistry<Claims>()` still compiles; an
99+
unannotated collection falls back to `cols: unknown` (the author casts, as
100+
today). `PkOf` degrades to `string` when the row is unknown.
101+
- **Zero runtime change.** The `collections`/`mutations` Maps stay string-keyed;
102+
the generic builder casts the typed def into the erased stored
103+
`MutationDef` at the storage boundary (one deliberate, isolated `as`).
104+
`MutationCtx` gains a third type param `TOp = MutOp` (default preserves
105+
existing internal call sites). Dispatch is untouched.
106+
107+
**Why B2 over A and B1.** Over **A**: A re-states the row type per mutation and
108+
can't check collection↔row; B2 declares it once and types both sides. Over
109+
**B1**: B1's co-location is nice but forces `()()` on every collection; B2 has no
110+
curry, and the manifest-apart-from-calls gap is itself type-checked
111+
(`table extends keyof TCols`). The deciding vote was the `()()` ergonomics
112+
against B2's single up-front declaration.
113+
114+
## Verification
115+
116+
The four ideas were compiled under `tsc --noEmit --strict` before deciding:
117+
A and B2 type-check with precise per-op `cols` (insert `Row`, update
118+
`Partial<Row>`, delete no `cols`); a generic *on `execute`* fails (the param is
119+
caller-chosen and opaque in the body); the accumulating `defineCollection<Row>`
120+
widens the table to `string`. These become permanent **type-level tests**
121+
(`tsc` expect-pass + `@ts-expect-error` expect-fail) alongside the unchanged
122+
runtime suite.
123+
124+
## Consequences
125+
126+
- Authors declare row types once (the manifest); handlers get precise
127+
`op.cols`/`op.key`; the `as Message` casts disappearincluding the ones in
128+
the README quick-start (added during the README pass as a stopgap).
129+
- The untyped path is unchanged at runtime and only mildly narrower at compile
130+
time (`cols: unknown` vs `Record<string, unknown>`); our own call sites cast,
131+
so nothing breaks.
132+
- Type-level tests join the suitea new category for this repo.
133+
134+
## Out of scope
135+
136+
- **Typed command `args`** (`defineCommand`). Commands are a different shape
137+
(free-form args, not a collection row), and their API status warrants its own
138+
review. Deferred to a follow-up.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ explains the displacement.
1717
| [0007](./0007-author-owned-schema-register-sync.md) | Author-owned schema; `registerSync` wires the sync | Accepted |
1818
| [0008](./0008-orphaned-cdc-triggers.md) | Orphaned CDC triggers when a collection is removed | Accepted |
1919
| [0009](./0009-changelog-time-retention.md) | Changelog time-based retention; reset stale reconnects | Accepted |
20+
| [0010](./0010-typed-mutations-collection-manifest.md) | Typed mutations via a collection-row manifest on `SyncRegistry` | Accepted |

examples/board/src/worker.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// `/bump` is a server-side load generator — it mutates random tasks (likely cold
1212
// ones the caller never loaded) and broadcasts, so the OTHER tab sees move-in.
1313

14-
import { Registry, SyncDurableObject } from "../../../src/server/index.ts"
14+
import { SyncRegistry, SyncDurableObject } from "../../../src/server/index.ts"
1515

1616
interface Env {
1717
BOARD_DO: DurableObjectNamespace
@@ -35,7 +35,7 @@ export class BoardDO extends SyncDurableObject<Env, Claims> {
3535
updated_at INTEGER NOT NULL
3636
)`)
3737
this.registerSync(
38-
new Registry<Claims>()
38+
new SyncRegistry<Claims>()
3939
.defineCollection({ table: "tasks", pk: "id" })
4040
.defineMutation({
4141
collection: "tasks",

examples/chat/src/worker.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// the real code with no build step for the lib. A published consumer would
55
// instead `import { ... } from "tanstack-do-db-collection"`.
66

7-
import { Registry, SyncDurableObject } from "../../../src/server/index.ts"
7+
import { SyncRegistry, SyncDurableObject } from "../../../src/server/index.ts"
88

99
interface Env {
1010
SESSION_DO: DurableObjectNamespace
@@ -27,7 +27,7 @@ export class SessionDO extends SyncDurableObject<Env, Claims> {
2727
created_at INTEGER NOT NULL
2828
)`)
2929
this.registerSync(
30-
new Registry<Claims>()
30+
new SyncRegistry<Claims>()
3131
.defineCollection({ table: "messages", pk: "id" })
3232
.defineMutation({
3333
collection: "messages",

examples/on-demand/src/worker.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// A GET /seed?room=… endpoint inserts fixed rows so subsets have pre-existing
33
// data to load (demonstrating loadSubset fetching, not just live inserts).
44

5-
import { Registry, SyncDurableObject } from "../../../src/server/index.ts"
5+
import { SyncRegistry, SyncDurableObject } from "../../../src/server/index.ts"
66

77
interface Env {
88
ITEMS_DO: DurableObjectNamespace
@@ -30,7 +30,7 @@ export class ItemsDO extends SyncDurableObject<Env, Claims> {
3030
created_at INTEGER NOT NULL
3131
)`)
3232
this.registerSync(
33-
new Registry<Claims>()
33+
new SyncRegistry<Claims>()
3434
.defineCollection({ table: "items", pk: "id" })
3535
.defineMutation({
3636
collection: "items",

src/server/changes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function initSchema(sql: SqlStorage): void {
5252
/**
5353
* Install AFTER INSERT/UPDATE/DELETE triggers copying change events into
5454
* `_sync_changes`. Idempotent. `tbl`/`pk` MUST be validated identifiers
55-
* (the Registry enforces this) — they are interpolated into DDL.
55+
* (the SyncRegistry enforces this) — they are interpolated into DDL.
5656
*
5757
* Each statement is passed whole: splitting on `;` would sever the inner
5858
* `INSERT ...;` from its `END`.
@@ -235,7 +235,7 @@ export function snapshotAll(sql: SqlStorage, tbl: string): Array<Record<string,
235235
}
236236

237237
/** Current rows for a set of keys, for hydrating deltas. `tbl`/`pk` are
238-
* validated identifiers (the Registry enforces this). */
238+
* validated identifiers (the SyncRegistry enforces this). */
239239
export function hydrateRows(
240240
sql: SqlStorage,
241241
tbl: string,

src/server/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
// diffs. Imports the workerd runtime (`cloudflare:workers`); not for browsers.
55
//
66
// - SyncDurableObject: hibernating-WebSocket base class.
7-
// - Registry: defineCollection / defineMutation / defineCommand.
7+
// - SyncRegistry: defineCollection / defineMutation / defineCommand.
88

9-
export { Registry } from "./registry.ts"
9+
export { SyncRegistry } from "./registry.ts"
1010
export type {
1111
CollectionDef,
1212
CommandCtx,
1313
CommandDef,
1414
MutationCtx,
15+
OpFor,
1516
MutationDef,
1617
} from "./registry.ts"
1718
export { SyncDurableObject } from "./sync-do.ts"

0 commit comments

Comments
 (0)