feat!: object sync-schema — defineSync, closed mutation trio, typed commands (ADR-0014)#19
Merged
Conversation
Replace the `new SyncRegistry().defineCollection()/defineMutation()/
defineCommand()` builder with `defineSync<User, Env>()` -> `{ collection,
command, schema }`. Mutations are a closed insert/update/delete trio
co-located on the collection (mirrors @tanstack/db's onInsert/onUpdate/
onDelete; a custom mutation type is structurally unrepresentable),
superseding ADR-0001 D11 and absorbing ADR-0010's row manifest. A
collection's Row comes from an explicit `collection<Row>(...)` generic, or
is inferred from the row schema on its insert mutation
(`collection({ mutations: { insert: { schema } } })`).
Commands are typed end to end: `type Api = typeof schema` drives a typed
`transport.call.<cmd>(args)` proxy and a typed `sendCall(name, args)`
(txId via crypto.randomUUID), and `doCollectionOptions<Api, "table">`
infers the row type from the schema. The optional Standard Schema slots
(insert.schema, update.schema, command schema) type cols/args; runtime
validation against them lands in a stacked follow-up PR.
Runtime dispatch/wire/CDC semantics are unchanged: `registerSync` compiles
the schema value into the same dispatch tables, so the suite passes as-is
(171/171). Breaking, no compat shim (pre-1.0).
BREAKING CHANGE: `SyncRegistry` and its defineCollection/defineMutation/
defineCommand builder are removed; author DOs with `defineSync`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr
Port the three existing examples off the removed SyncRegistry builder to the object-schema API (ADR-0014): each DO authors `defineSync<Claims, Env>().schema(...)` and exports `type <Name>Api = typeof schema`; the client types its transport `WebSocketTransport<Api>` and infers collection rows via `doCollectionOptions<Api, "table">`. chat's "clear room" is now a typed `transport.call.clearRoom()`. Behavior unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr
A new example with two separate Durable Objects (RoomDO + InboxDO) behind one Worker. The client opens one transport PER DO and exposes them through a React SyncProvider/useSync keyed by DO, so each DO's typed `transport.call` namespace stays disjoint. A cross-DO feed is merged client-side (the DO never joins, ADR-0001). Also indexes the examples. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr
…n types - ADR-0014: rewrite D3 (and the consequences bullet) to the landed design — the row schema lives on `mutations.insert.schema` (infers Row + validates the full row), `update.schema` validates the partial patch, command schema validates args, delete is unvalidated. Documents the gate-not-parser rule. Drops the stale positional `collection(zMessage, def)` form and the old "update/delete unvalidated" framing. The ADR is evergreen — it records the decision; this base branch wires the runtime in the stacked follow-up. - Drop the vestigial `OpFor` type (and its re-export). The authoring API uses `InsertOp`/`UpdateOp`/`DeleteOp` directly; nothing referenced `OpFor`. - registry-types.ts: pin the new typing — Row inferred from `insert.schema`, `update.schema` typed `StandardSchemaV1<Partial<Row>>`, and the trio stays closed with a schema present. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr
… types Four task-oriented guides under recipes/: - commands-vs-mutations.md — when each fits, the optimistic vs RPC split. - on-demand-and-windows.md — syncMode: "on-demand", per-query subsets, and windowed pagination with useLiveInfiniteQuery. - server-originated-writes.md — runSyncedWrite for webhooks/jobs/seeds, and afterCommit for post-commit work. - end-to-end-types.md — `type Api = typeof schema` as the shared contract. The Standard Schema validation recipe is intentionally not here; it lands with the runtime-validation follow-up that 3154e40 set up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr
The note claimed a command's authorize error reaches the client as a generic rejection (the behavior on this branch, ADR-0012). The stacked validation branch makes command authorize/validation errors surface like a mutation's, so that detail would contradict it after a rebase. Drop the branch-specific surfacing claim and keep the part that holds everywhere: both deny by throwing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr
Two type-soundness fixes from the codex review of PR #19: - WebSocketTransport only used `Api` in its command methods, so two transports with the same (or empty) command maps were interchangeable across DOs — you could pass an InboxDO transport to a RoomDO collection and it type-checked. Add a phantom `__api` brand so a transport for one schema is not assignable where another schema is expected. This catches the mutations-only multi-DO case. - An empty command name `""` type-checked on `sendCall`/`call`, but `wellFormed` drops a call frame with an empty name, so the call would hang to timeout. Exclude `""` from the callable command names, making it harmless dead code at no runtime cost. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr
A mutation's `execute` runs inside `transactionSync`, which cannot await, and an async execute could not be atomic with its CDC rows anyway. The type can't forbid async (TS lets an async fn satisfy a void callback), so make the runtime guard's message point to the right homes for async work, and add a recipe note: do async work in authorize, afterCommit, or a command. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr
…Row (codex #3) Finish the branch's purpose for collection creation: there is now ONE way to make a collection, derived from the schema. The explicit-Row overload `doCollectionOptions<Row>({ ... })` and the `DoCollectionOptions` type are removed. The single Api-driven form infers `Api` from the (branded) transport and the row from the `table` literal, so the call is just `doCollectionOptions({ transport, table, getKey })`, and a typo'd table is a type error rather than falling through to a catch-all (closes codex review #3). The explicit-Row form was a pre-schema vestige — end users always have the schema `Api`, so it was only ever a second way to do the same thing. Migrate the test suite to the typed form (export `TestApi` from test-worker; type the transports `<TestApi>`; drop the `<Msg>`/`<File>` args, row now inferred) and simplify the examples to the zero-type-arg form. Type-only change; 171/171 unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DxmkLhbtb5w7oHHiJKprr
…i-do boundary Confidence pass over the object-schema branch before merge: - README: add the multi-do example and a "Common patterns and recipes" list linking each recipe in recipes/ (the folder had no inbound links). - ADR-0004: drop the dead `defineCommand` name from an Accepted (current-reading) ADR body; the concept sentence stands without it. - examples/multi-do: note that InboxDO's per-user boundary is illustrative, not enforced (`/inbox/:user` names the DO, `?user=` names the caller; the example does not bind them). - recipes/commands-vs-mutations: a command's authorize/execute errors are sanitized, so the client gets a generic rejection, not the thrown text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pre-1.0 breaking change. Replaces the
SyncRegistrybuilder with an object schema, makes commands typed end to end, and adds a multi-DO example. Offmain, ahead of the SSR branch.What changed
Authoring (
src/server/registry.ts).new SyncRegistry().defineCollection().defineMutation().defineCommand()→defineSync<User, Env>()→{ collection, command, schema }:mutationsis exactlyinsert?/update?/delete?— a custom type is structurally unrepresentable (mirrors@tanstack/db'sonInsert/onUpdate/onDelete). Supersedes ADR-0001 D11; absorbs ADR-0010's row manifest (the row type lives on the collection, not a third generic).type Api = typeof schemais the whole client contract:new WebSocketTransport<Api>()→ typedtransport.call.<cmd>(args)proxy + typedsendCall(name, args)(txId viacrypto.randomUUID());doCollectionOptions<Api, "table">infers the row type.cols+ commandargsat runtime as a gate — the original value flows to handlers, no transforms/defaults applied (seerecipes/zod-standard-schema-collections.md).Runtime dispatch / wire / CDC semantics are unchanged —
registerSynccompiles the schema value into the same dispatch tables, so the suite passes as-is.New example:
examples/multi-doTwo separate DOs behind one Worker, one transport per DO, a React
SyncProvider/useSynckeyed by DO so command namespaces never collide, and a client-side cross-DO feed (the DO never joins — ADR-0001).Commits
feat(registry)!:library core + tests + ADR-0014 + CHANGELOGrefactor(examples):migrate chat/board/on-demandfeat(examples):multi-DO showcasedocs:therecipes/Standard Schema guideVerification
tsc --noEmit: 0 errors (src + tests).vitest run: 171/171 across 41 files (was 172; one test dropped — "define same collection twice" is now structurally unrepresentable, guard kept defensively, documented in the test + ADR).transport.call.anything()type-check (fixed viaschemaoverload, proven with a@ts-expect-errorprobe); Standard Schema discarded the parsed value (resolved as a documented validation gate)..tsxfiles retain pre-existing@tanstack/dbdual-copy tsc noise (unrelated to this change).🤖 Generated with Claude Code