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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
While pre-1.0, the public API may change between 0.x releases.

## [Unreleased]

### Changed

- **Rejection reasons are surfaced uniformly for mutations and commands (revises
ADR-0012 D3).** An `authorize` throw or a schema validation failure now reaches
the client with its reason (validation failures carry a `VALIDATION` code);
only `execute` errors stay sanitized. Previously a command's `authorize` error
was sanitized like its `execute`, unlike a mutation's.

### Added

- **Optional Standard Schema validation (ADR-0014).** A collection's
`insert.schema` (the row schema, which also infers the collection's Row) and
`update.schema` (a partial patch schema), and a command's schema, are checked
at runtime and rejected loudly on failure. Any `~standard` library works (zod,
valibot, arktype) and the framework adds no validator dependency. It is a gate,
not a parser: the original value flows to handlers, so schemas must not rely on
transforms, defaults, or coercion. See
`recipes/zod-standard-schema-collections.md`.

## [0.4.0] — 2026-07-01

### Changed
Expand Down
31 changes: 19 additions & 12 deletions docs/adr/0014-object-sync-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
**Status:** Accepted. Supersedes [ADR-0001](./0001-sync-architecture.md) D11's
`defineMutation`/`defineCommand` builder and the [ADR-0007](./0007-author-owned-schema-register-sync.md)
`new SyncRegistry().defineCollection(…)` chain; closes the ADR-0010 manifest and
its "typed command args" out-of-scope follow-up. Hard breaking change to the DO
authoring API and the typed client surface (pre-1.0, clean break).
its "typed command args" out-of-scope follow-up; revises [ADR-0012](./0012-wire-input-hardening.md)
D3 so a command's `authorize`/validation errors surface like a mutation's. Hard
breaking change to the DO authoring API and the typed client surface (pre-1.0,
clean break).

## Context

Expand All @@ -28,8 +30,11 @@ We also had two genuinely different shapes wearing one builder. A **mutation**
mirrors a row write — TanStack DB already names exactly `onInsert`/`onUpdate`/`onDelete`,
and a synced write *is* one of those three. A **command** is an RPC: free-form
args, a return value, its own atomicity, no collection. ADR-0010 lumped them as
sibling `defineX` methods; ADR-0012 D3 had already found they diverge at runtime
(a command's `authorize` throw is sanitized, a mutation's stays user-facing).
sibling `defineX` methods, but they stay distinct in what's structural: a command
owns its atomicity and may be async; a mutation is synchronous inside the
transaction. (ADR-0012 D3 had also split their error surfacing — command
`authorize` sanitized, mutation user-facing — but D2 below drops that, so
surfacing is now uniform.)

## Decision

Expand Down Expand Up @@ -95,11 +100,12 @@ This is the open counterpart to the closed trio: anything that isn't one-row
insert/update/delete (multi-row, cross-collection, compute-and-return, no write at
all) is a command.

The two shapes stay distinct at runtime exactly as ADR-0012 D3 found: a command's
`authorize` throw is logged and surfaced as a generic rejection; a mutation's
`authorize` throw is a distinct user-facing message. Keeping them separate
helpers, rather than ADR-0010's sibling `defineX` methods, makes that divergence
structural instead of incidental.
They stay separate helpers because their shapes differ — a command is RPC with
free args and a return value, a mutation is a typed row op — not because their
errors differ. Error surfacing is now the **same** for both (revising ADR-0012
D3, which had sanitized a command's `authorize`): a mutation and a command both
surface their `authorize` and validation errors to the client, and both sanitize
their `execute` errors. See D3.

### D3: The row schema lives on the insert mutation; it infers the row and validates writes

Expand Down Expand Up @@ -203,8 +209,9 @@ mut/call handler, `runSyncedWrite` is still the path (ADR-0006).
patch, a command schema validates `args`; a `delete` is unvalidated (no cols, pk
validated at insert). It validates but does not transform — the original value
flows to handlers. Dependency-free via Standard Schema; no zod runtime pulled in.
- **`authorize` divergence is now structural** (ADR-0012 D3): mutation-authorize
throws stay user-facing, command-authorize throws are sanitized — the two
helpers make that explicit rather than a shared-code accident.
- **Uniform error surfacing (revises ADR-0012 D3).** Mutations and commands both
surface `authorize` and validation errors (a schema failure carries a
`VALIDATION` code) and both sanitize `execute` errors. ADR-0012 D3's
command-`authorize` sanitization is dropped, so the two behave the same.
- **Per-DO contract.** Multi-DO apps run one transport and one `Api` per DO; there
is no global command registry. Consistent with the per-connection cursor (0002).
10 changes: 6 additions & 4 deletions recipes/commands-vs-mutations.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,12 @@ returns the count.

## Notes

- A mutation's `authorize` denies a write by throwing, and the client receives
that error message. A command's `authorize` denies a call the same way, by
throwing, but a command's errors are sanitized — the client receives a generic
rejection rather than the thrown text.
- Denial is uniform across mutations and commands: an `authorize` throw, or a
schema validation failure, surfaces its reason to the client (validation
failures carry a `VALIDATION` code), so you can tell the user why. Only an
`execute` error is sanitized to a generic message ("mutation failed" /
"command failed") — by then the call is authorized, and a failure there may
carry internal detail.
- You can type a command's args, either with a generic
(`sync.command<{ before: number }>()(fn)`) or from a schema
(`sync.command(zArgs, fn)`).
Expand Down
8 changes: 5 additions & 3 deletions recipes/end-to-end-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ Pick whichever you prefer.

- Pass the row type as a generic: `sync.collection<Message>({ pk: "id", mutations })`.
- Infer it from a schema on the insert mutation: `sync.collection({ pk: "id",
mutations: { insert: { schema: Message, execute } } })`. On this branch the
schema only infers the type. A runtime check against the schema is a separate
follow-up.
mutations: { insert: { schema: Message, execute } } })`. The schema infers the
type and also validates the value at runtime. See
`recipes/zod-standard-schema-collections.md`.

## Notes

Expand All @@ -62,3 +62,5 @@ Pick whichever you prefer.

- ADR-0014 describes the schema as the shared contract.
- `examples/chat` types its transport and collection from `ChatApi`.
- `recipes/zod-standard-schema-collections.md` covers validating rows and command
args with a schema.
124 changes: 124 additions & 0 deletions recipes/zod-standard-schema-collections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Validate collections and commands with a schema

Use this when you want the Durable Object to reject a malformed write at the edge,
such as a bad insert row or bad command arguments, using a schema library you
already have. It works with zod, valibot, or arktype, and the framework does not
depend on any of them.

The schema is optional. If you leave it off, you pass the row type as a generic
(`collection<Message>({ ... })`) and nothing is checked at runtime. Add a schema
when you want the runtime check as well.

## Recipe

```ts
// server
import { z } from "zod" // 3.24 or later; any Standard Schema library works
import { defineSync } from "tanstack-do-db-collection/server"

const Message = z.object({
id: z.string(),
author: z.string(),
content: z.string().min(1).max(4000),
created_at: z.number().int(),
})

const sync = defineSync<Claims, Env>()

export const schema = sync.schema({
collections: {
messages: sync.collection({
pk: "id",
mutations: {
// The insert schema checks the full row. Its type also becomes the
// collection's Row, so you do not pass a <Row> generic.
insert: {
schema: Message,
execute: ({ op, sql }) =>
sql.exec(
"INSERT INTO messages(id, author, content, created_at) VALUES (?, ?, ?, ?)",
op.cols.id,
op.cols.author,
op.cols.content,
op.cols.created_at,
),
},
// An update sends a partial patch, so pass a partial schema.
update: {
schema: Message.partial(),
execute: ({ op, sql }) => {
if (op.cols.content !== undefined) {
sql.exec("UPDATE messages SET content = ? WHERE id = ?", op.cols.content, op.key)
}
},
},
delete: { execute: ({ op, sql }) => sql.exec("DELETE FROM messages WHERE id = ?", op.key) },
},
}),
},
commands: {
// A command checks its arguments against the schema you pass first.
clearOlderThan: sync.command(z.object({ before: z.number().int() }), ({ args, sql }) => {
sql.exec("DELETE FROM messages WHERE created_at < ?", args.before)
return { ok: true }
}),
},
})
export type Api = typeof schema
```

## What gets checked

- **Insert.** The insert schema checks the full row before the write runs.
- **Update.** The update schema checks the partial patch. You pass a partial
schema because an update sends only the fields that changed, not the whole row.
A full row schema would reject every valid partial.
- **Delete.** A delete needs no schema. It carries only the key, the framework
already checks that the key is a non-empty string, and the primary key was
checked when the row was inserted.
- **Command.** A command checks its arguments against the schema you pass.

When a check fails, the write is rejected and nothing is applied.

## The schema is a gate, not a parser

The schema checks the value and rejects it on failure. It does not change the
value. Your handler receives the original value from the wire, not the schema's
parsed output. So do not rely on `.transform()`, `.default()`, `z.coerce`, or
unknown-key stripping to reshape what gets written, because the parsed result is
thrown away. Use schemas where the input equals the output. Constraints like
`.min()`, `.max()`, and `.refine()` are fine, because they check the value and do
not reshape it.

The reason is that a synced row is applied on the client the moment it is sent. If
the server changed the row during a write, the stored row would no longer match
the client's copy, and the correction would overwrite it. Changing the primary
key would also break the rule that the optimistic id equals the confirmed id
(ADR-0001 D9).

## Any Standard Schema works

The slot accepts any library that implements the
[Standard Schema](https://github.com/standard-schema/standard-schema) interface,
such as zod 3.24 or later, valibot, or arktype. The framework imports no
validator, so you bring your own. To switch libraries, replace `z.object({ ... })`
with the equivalent from your library and change nothing else.

## Notes

- **Types follow the schema.** The row type and the argument type come from the
schema you pass, so `op.cols` and `args` are typed exactly as the schema
describes.
- **A failed validation tells the client why.** For both mutations and commands,
a schema failure (or an `authorize` throw) is surfaced to the client with its
reason and a `VALIDATION` code, so you can show the user what was wrong. Only an
`execute` error is sanitized to a generic message.
- **It runs on every matching write,** so keep schemas cheap.

## See also

- ADR-0014 for the object schema and why the slot is a gate and not a parser.
- ADR-0012 for wire input hardening; ADR-0014 for how authorize/validation errors
are surfaced (revising ADR-0012 D3).
- `examples/chat` for a collection and command with no schema, as a baseline to
compare against.
Loading