Domain commands
A JSON Patch says “put this value here”. A
domain command says “perform this operation against the state you find”:
postMessage, editMessage, increment. The app registers a mutator per
command — a pure function from the document, the command’s arguments and a
server-stamped context to the document’s new data, or a refusal — and gives the
same registry to the client and the server. The client runs the mutator over
its local copy for an optimistic prediction; the server runs it over the
document it holds, and that run is the one that counts. The mutator gets its
own copy of the document either way, so it may edit the data in place and
return it, as an update callback does.
import { defineCommands } from "@repo/datadata/commands";
export const COMMANDS = defineCommands(SCHEMAS, { chat: (command, schema) => { // The message's own shapes, minus what the server stamps. const { authorId, postedAt, ...authored } = schema.root.fields.messages.values.fields; return { postMessage: command({ args: { type: "object", fields: { id: { type: "string" }, ...authored } }, contract: "relative", run: (doc, args, { principal, now }) => { if (args.id in doc.data.messages) { return { refuse: { code: "duplicateId", message: `${args.id} is taken` } }; } const message = { ...args, authorId: principal.subject, postedAt: now }; return { data: { ...doc.data, messages: { ...doc.data.messages, [args.id]: message } } }; }, }), }; },});
client.live.command({ docId, type: "chat", name: "postMessage", args: { id, body } });await client.live.commandAndWait({ docId, type: "chat", name: "editMessage", args: { id, body } });The registry goes to createClient({ commands }) and createServer({ commands })
(the Durable Object and the Node server take it the same way). The client’s
sessions and the server’s commandDocument are typed by it: type names the
document’s type, name must be a command defined for it and args must fit
that command’s shape, all checked at compile time, and a type that is not the
document’s own is refused. It is keyed by
document type first: a command belongs to the type it is defined under, so two
types may each have a rename with arguments of their own. Each callback also
receives the type’s schema, so an arguments shape is picked from the document’s
own field shapes, constraints included, rather than written again; the type and
the validation of the arguments then come from the one definition. Running a
command on a type that does not define it, or with arguments that fail their
shape, is refused before any mutator runs.
Why not a patch
Section titled “Why not a patch”The mutator reads facts the client cannot be trusted with — the server’s clock,
the connection’s principal — and encodes rules a patch cannot carry: only the
author may edit their message, a counter increments whatever it finds. Two
increments from two users make +2, because each runs against the count it
meets on the server; two patches to the same path would make +1. And the
server validates the mutator’s result against the document type’s schema like
any other write, so a command cannot produce a document a patch could not.
Contracts and guards
Section titled “Contracts and guards”A command declares what it promises under concurrency, and the client derives the guard from it:
relative— the operation applies to whatever the server holds (increment). No guard.overwrite— the later command wins, as two patches on one path would (editMessage). No guard; a mutator that wants more compares a revision it is handed in the arguments with the one it finds, and refuses.exact— compare-and-set over the whole document the caller saw. The command carries a"sequence"guard and is refusedpreconditionFailedif the document moved.
Prediction
Section titled “Prediction”The client applies the mutator’s result to its optimistic view at once, like an
update. The difference is that the prediction is re-executed: each render
runs the pending command again over the document as that render meets it, so a
command queued behind another pending write renders its effect over that write,
as the server will run it. An exact command is the exception: it renders its
effect only while the document is the one the caller saw, and nothing once
another write lands under it, since the server will refuse it. A prediction the
client cannot make — no principal is known yet, the mutator refuses on this
copy — renders nothing, and the server’s answer decides.
Writes on one document are sent one command-bounded step at a time: a command
goes once every earlier write on the document is acknowledged, and an update
staged behind a pending command waits for the command’s answer, because the
command’s effect over those writes is decided on the server. With
offline persistence a pending command is
journaled like any other write, in its place among the document’s updates, so a
reload replays it under its original id ahead of the updates staged behind it,
and renders it re-executed over the document as before. No sys: document
takes a command: one sent at a system document is refused unknownCommand.
Refusals
Section titled “Refusals”A mutator refuses with a code of its own — "notAuthor", "duplicateId" —
and the server answers a doc:error of category commandRefused carrying it.
The originator rolls its prediction back, commandAndWait rejects with a
CommandRefusedError exposing the code, and the fire-and-forget form reports it
through onWriteError. Three codes are the library’s: unknownCommand,
invalidArgs and mutatorError (the mutator threw). A refusal is a benign
outcome, like a guard miss: the document is intact, and the caller decides what
to do with the code.
Authorization
Section titled “Authorization”Each command is its own access kind: a schema’s
access block grants it under commands, by name, with the same rule
vocabulary as the write kinds.
access: { update: { role: "admin" }, commands: { postMessage: { role: "editor" }, editMessage: { role: "editor" } },},A command’s default is nobody — it reads only its own rule, never the
update rule or the write fallback — so registering a command grants nothing
until the schema names it. That is what makes commands a narrower lane than raw
updates: the chat above lets editors post and edit through the mutators, while
a raw doc:update to the chat needs an admin. The client’s can() answers for
a command kind like any other, so a UI can hide what the principal cannot run.
A command needs read access like an update does, and the server decides
authorization before it consults the registry, so a refusal never reveals which
commands exist.
Server agents
Section titled “Server agents”Code running beside the server, such as an AI agent in a Durable Object, runs a
command with server.commandDocument(context, { docId, type, name, args }), the
direct counterpart of server.updateDocument. It runs as the context’s
principal and meets the same gates as a client’s command, so an agent can be
granted the few commands it needs instead of update on the whole document.
The mutator’s context carries that principal and the server’s clock. A refusal
throws a RefusedCommandError carrying the mutator’s code, the library’s
refusals throw the same error with their own codes, an authorization failure
throws too, and nothing is stored. The call runs against the document as the
server holds it, with no guard, whatever the command’s contract.