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(), no Math.random(), no generated ids in the body; they diverge between the optimistic and authoritative runs. The caller passes id and createdAt.
  • 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:

  • insert writes a row only when its primary key is free: skip if it already exists.
  • upsert writes the row unconditionally, clobbering every column: overwrite from scratch.

Reaching for upsert “just in case the mutator re-fires” silently wipes existing data on replay. For a partial change, read-then-branch: if (existing) update the patch, else insert the full row.

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 force userId from the server-side identity. Clients must never pass their own userId, 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, or delete replaces the generated operation, so its body must validate its own write with ctx.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 offloaded with the server-only server.enqueueTask(async () => { … }), which fires once after commit. Never use it to re-enter a Zero mutation; that is a normal inline write. And no belt-and-suspenders retry loops; write the change once.

Ready?

Create a web, iOS, and Android app in minutes with agents working alongside you.