Mutators
The rules for writing data.
Apps change data through mutators. A mutator is special because its body runs three times for one logical change: optimistically on the client, rebased on the client as other changes arrive, and authoritatively on the server. Every rule below exists to make those three runs agree.
Be deterministic
The body must produce the same result every time it runs. So nothing non-deterministic goes inside it:
- No
Date.now(), noMath.random(), no generated ids in the body; they diverge between the optimistic and authoritative runs. The caller passesidandcreatedAt. - Derive child ids and timestamps from what you already have:
`${parent.id}-thread`,const now = props.createdAt.
Use CRUD by intent
Write through the standard insert / update / delete / upsert operations,
chosen by what you mean. Don’t invent custom mutator names for a single-table
write; custom names are for cross-table actions only. The two creating verbs are
not interchangeable:
insertwrites a row only when its primary key is free: skip if it already exists.upsertinserts when the key is free and otherwise patches the columns you passed: create, or update what you sent. Columns you omit keep their current values; an explicitnullclears a column.
Pick the verb that matches the intent: insert for a row that may already
exist, update for a row that does, upsert only when the call genuinely
means either. A replayed upsert still overwrites other edits in the columns
it carries, so send only the columns this call owns.
Reads: inside vs outside the mutator
- Inside a mutator, reads from the transaction are supported and correct for read-modify-write and existence checks. The one anti-pattern is reading back a row you just wrote in the same transaction; construct the merged object inline instead.
- Outside a mutator, client code reaches the server only through named queries. An inline ad-hoc query on the client resolves against the local cache only and silently returns empty for rows nothing has synced, the “works here, empty there” bug. The fix is to add those rows to a synced query, never to force a slow server round-trip.
Auth and permissions
- Use
ensureLoggedIn()for auth and forceuserIdfrom the server-side identity. Clients must never pass their ownuserId, so don’t put it in the props shape at all. - Generated CRUD slots apply the permission you declare automatically. A
custom
insert,update,upsert, ordeletereplaces the generated operation, so its body must validate its own write withctx.can()or other deterministic logic that throws. Check before update/delete and after insert/upsert so a query-based permission can see a new row.
Keep the body fast
The body runs in the transaction and blocks it, so it must be quick. Anything slow
or external (an LLM call, a git or filesystem operation, a network request,
heavy compute) is scheduled as a post-commit task on ctx.server, which exists
only on the server run and fires once after the authoritative commit:
The guard is what makes this safe: the same body also runs optimistically on
the client, where ctx.server is undefined, so scheduling without it crashes
the client run. Resolve anything the task needs after the guard while the
transaction is still open. Tasks are fire-and-forget with no retry, so never
use one to re-enter a Zero mutation; that is a normal inline write. And no
belt-and-suspenders retry loops; write the change once.