Skip to content

feat!: object sync-schema — defineSync, closed mutation trio, typed commands (ADR-0014)#19

Merged
grrowl merged 10 commits into
mainfrom
feat/sync-schema
Jul 1, 2026
Merged

feat!: object sync-schema — defineSync, closed mutation trio, typed commands (ADR-0014)#19
grrowl merged 10 commits into
mainfrom
feat/sync-schema

Conversation

@grrowl

@grrowl grrowl commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Pre-1.0 breaking change. Replaces the SyncRegistry builder with an object schema, makes commands typed end to end, and adds a multi-DO example. Off main, ahead of the SSR branch.

What changed

Authoring (src/server/registry.ts). new SyncRegistry().defineCollection().defineMutation().defineCommand()defineSync<User, Env>(){ collection, command, schema }:

const sync = defineSync<Claims, Env>()
export const schema = sync.schema({
  collections: {
    messages: sync.collection<Message>({
      pk: "id",
      mutations: { insert: { authorize, execute }, update: { execute }, delete: { execute } },
    }),
  },
  commands: { clearRoom: sync.command()(({ sql }) => ({ deleted: clearAll(sql) })) },
})
export type Api = typeof schema
  • Closed mutation trio. mutations is exactly insert?/update?/delete? — a custom type is structurally unrepresentable (mirrors @tanstack/db's onInsert/onUpdate/onDelete). Supersedes ADR-0001 D11; absorbs ADR-0010's row manifest (the row type lives on the collection, not a third generic).
  • Typed commands, end to end. type Api = typeof schema is the whole client contract: new WebSocketTransport<Api>() → typed transport.call.<cmd>(args) proxy + typed sendCall(name, args) (txId via crypto.randomUUID()); doCollectionOptions<Api, "table"> infers the row type.
  • Optional Standard Schema slot. zod/valibot/arktype (no validator dependency); validates insert cols + command args at runtime as a gate — the original value flows to handlers, no transforms/defaults applied (see recipes/zod-standard-schema-collections.md).

Runtime dispatch / wire / CDC semantics are unchangedregisterSync compiles the schema value into the same dispatch tables, so the suite passes as-is.

New example: examples/multi-do

Two separate DOs behind one Worker, one transport per DO, a React SyncProvider/useSync keyed by DO so command namespaces never collide, and a client-side cross-DO feed (the DO never joins — ADR-0001).

Commits

  1. feat(registry)!: library core + tests + ADR-0014 + CHANGELOG
  2. refactor(examples): migrate chat/board/on-demand
  3. feat(examples): multi-DO showcase
  4. docs: the recipes/ Standard Schema guide

Verification

  • 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).
  • Codex adversarial pass found two issues, both addressed: a commandless schema let transport.call.anything() type-check (fixed via schema overload, proven with a @ts-expect-error probe); Standard Schema discarded the parsed value (resolved as a documented validation gate).
  • Example client .tsx files retain pre-existing @tanstack/db dual-copy tsc noise (unrelated to this change).

🤖 Generated with Claude Code

@grrowl grrowl force-pushed the feat/sync-schema branch from 8980b6b to 800ed4e Compare June 29, 2026 06:33
grrowl and others added 3 commits June 29, 2026 17:11
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
grrowl and others added 7 commits June 29, 2026 17:39
…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>
@grrowl grrowl marked this pull request as ready for review July 1, 2026 02:10
@grrowl grrowl merged commit 883db11 into main Jul 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant