Skip to content

Mutator Rules

Mutators run twice: optimistically on the client, then authoritatively on the server. They can also replay during rebase when other mutations arrive from the server. The rules below make replay and double-execution safe.

1. Use Explicit Values, Not Toggles

Replay sees the current state, not the state at the time the mutation was created. A toggle (!current.done) can flip the wrong way if another user changed the value in between.

typescript
// BAD: can flip the wrong way during rebase
await ctx.table("todos").set({ itemKey: id, value: { ...todo, done: !todo.done } });

// GOOD: pass the intended value
await ctx.table("todos").set({ itemKey: id, value: { ...todo, done: true } });

2. Generate IDs Outside Mutators

Mutators run on both client and server. Generating IDs inside produces different IDs on each run, splitting the "same" mutation into two different rows.

typescript
// BAD: different ID on client vs server
addTodo: async (ctx, { text }) => {
  const id = Math.random().toString();
  await ctx.table("todos").set({ itemKey: id, value: { text } });
},

// GOOD: generate outside, pass in
const id = crypto.randomUUID();
await store.mutate.setTodo({ id, text: "Buy milk" });

Same applies to Date.now() — pass timestamps in from the client instead of reading the clock inside the mutator.

3. Read Before Writing

Don't assume what exists in storage. Read current state, then merge:

typescript
setTodo: async (ctx, input) => {
  const existing = await ctx.table("todos").get(input.id);
  await ctx.table("todos").set({
    itemKey: input.id,
    value: {
      text: input.text ?? existing?.text ?? "",
      completed: input.completed ?? existing?.completed ?? false,
    },
  });
},

This makes the mutator safe as both a create and an update, and safe to replay against state modified by other mutations.

Why These Matter

All three rules protect against the same failure mode: the mutator runs more than once (client + server + replay), and each run should converge on the same logical outcome. Break any of them and your local state diverges from the server's authoritative state, surfacing as "my change disappeared" or "duplicate entries" bugs.

4. Validate Before the isServer Gate

When a mutator can validate its input from public/own-private data, do that validation above any if (!ctx.isServer) return; so the throw runs on the client's optimistic pass.

typescript
// BAD: server-only throw silently disappears on the client
makeMove: async (ctx, input) => {
  if (!ctx.isServer) return;
  const game = await ctx.table("game").get("state");
  if (game?.currentPlayer !== ctx.userId) throw new Error("Not your turn");
  // ... rest of move logic
},

// GOOD: validation runs on both passes; bad calls reject the outer
// `await store.mutate.makeMove(...)` synchronously on the client
makeMove: async (ctx, input) => {
  const game = await ctx.table("game").get("state");
  if (game?.currentPlayer !== ctx.userId) throw new Error("Not your turn");
  if (!ctx.isServer) return;
  // ... server-only work that depends on serverOnly() data
},

Why: server-side throws are silently rolled back

A throw inside if (ctx.isServer) { ... } does not reject the outer await store.mutate.X(...) promise, and it does not reject .confirmed. The SDK rolls back the optimistic mutation and resolves both promises with undefined. The user sees their action evaporate with no error.

To surface server-rejected mutations, either:

  1. Restructure validation so it runs on the optimistic pass too (preferred — see above).
  2. Subscribe to store.onFailedMutation((info) => { ... }) and react to the error_type (e.g., show a toast).

If you genuinely need to validate against server-only data (serverOnly() table, randomness, action results), the silent rollback is the only behavior available — accept it and surface failure through onFailedMutation or a derived UI state.

What you can/can't read on the client

SourceClient validation works?
ctx.table("name") (public)Yes
ctx.privateOfUser(ctx.userId).table(...)Yes
ctx.privateOfUser(otherUserId).table(...)Throws on client
ctx.serverOnly().table(...)Throws on client

So: turn order, slot/membership checks, phase gates → validate up top. Move legality (against a server-only board), randomness, action outputs → server-only.

Reference

poe-apps/codenames/synced-store/mutators.ts startGame is the canonical pattern: throws on missing roles using public state, then if (!ctx.isServer) return; for the randomness block.