A mutator describes a write, such as creating an issue or changing its status. In a synced app, a named mutator lets the browser predict that write before the server accepts it. An isomorphic mutator shares one implementation between the browser and API server.
The two executions use different data. The browser can hold only part of the database, and the server can reject a write. Shared code keeps the write rules together. Rebase reconciles the prediction with the authoritative result.
This page covers the shared mutation API used in the synced-app quickstart. Direct SQL writes and embedded engine writes do not require this API.
One body, two drivers
A shared mutator is a synchronous JavaScript generator. Each yield pauses
its body and returns a logical database operation, such as tx.insert(...).
The driver performs that operation and resumes the generator with the result.
Each tier supplies its own driver:
- The browser drives the body synchronously against its local wasm engine. Every affected view updates before the call returns. The prediction is re-invoked on every rebase, so the body must be deterministic (rules below).
- The API server drives the same body asynchronously inside an authoritative transaction, rendering each yielded op to dialect SQL. See the API server for the wiring.
Pair each body with the schema for its args at one site: shared(args, gen).
Bind shared to your schema with defineMutators. Then every op checks its
table, column names, value types, and pk columns at compile time:
// shared/app-def.ts — imported by BOTH the browser and the API server
import { defineMutators } from "@rindle/client";
import type { MutationGen, MutatorCtx, Row } from "@rindle/client";
import type { ClientRegistry } from "@rindle/optimistic";
import { z } from "zod";
import { schema, issue } from "./schema.gen.ts";
const { shared } = defineMutators(schema);
export const createIssueArgs = z.object({
id: z.string(), title: z.string(), status: z.string(), priority: z.string(), createdAt: z.number(),
});
export type CreateIssueArgs = z.infer<typeof createIssueArgs>;
// Normalization runs INSIDE the one body, so both tiers normalize identically.
export function cleanTitle(t: string): string { return t.trim().slice(0, 200); }
export const mutators = {
createIssue: shared(createIssueArgs, function* (tx, a: CreateIssueArgs, ctx: MutatorCtx): MutationGen {
const title = cleanTitle(a.title);
if (!title) return; // a no-op prediction is fine
yield tx.insertIgnore("user", { id: ctx.user, name: ctx.user });
yield tx.insert("issue", {
id: a.id, title, status: a.status, priority: a.priority,
ownerId: ctx.user, createdAt: a.createdAt, updatedAt: a.createdAt,
});
}),
setStatus: shared(
z.object({ id: z.string(), status: z.string(), updatedAt: z.number() }),
function* (tx, a): MutationGen {
yield tx.update("issue", { id: a.id, status: a.status, updatedAt: a.updatedAt });
},
),
} satisfies ClientRegistry;
The arg schema does double duty. The server parses the untrusted wire args
through it before the body runs — a failed parse is a hard reject. Both
tiers derive the arg type from it with z.infer. The client trusts its typed
callsites and skips the parse.
The op vocabulary
tx is a stateless effect factory — every method just builds an op to
yield (it performs no I/O). Ops are keyed by column name, independent of
column order:
yield tx.insert(table, row)— the insert shape. Nullable columns can be omitted and default to null; non-null columns are required.yield tx.update(table, row)— the pk plus only the columns that change. A missing row is a no-op.yield tx.upsert(table, row)— an insert shape. Replaces the non-pk columns on a pk conflict.yield tx.insertIgnore(table, row)— an insert shape. Does nothing on a pk conflict (rendersON CONFLICT DO NOTHINGserver-side). The isomorphic twin ofif (!exists) insert.yield tx.delete(table, { pk })— pk columns only.
A mutator that spans several tables yields each op in turn. Helpers
follow one convention: a multi-op (or reading) helper is itself a generator and is
spread with yield* (yield* applyTags(tx, a)). A single-op helper returns one
op and is plain-yielded. Prefer returning ops for single-op helpers — a
forgotten yield leaves an obvious dead statement, where a forgotten yield* on
a generator is a silent no-op.
Reads inside a mutator
A read is a yield whose expression evaluates to the result — the one yield suspends the generator while the driver resolves it and feeds it back:
closeIssue: shared(
z.object({ id: z.string(), updatedAt: z.number() }),
function* (tx, a, ctx): MutationGen {
const current = (yield tx.row("issue", { id: a.id })) as Row<typeof issue> | undefined;
if (!current || current.ownerId !== ctx.user) return;
yield tx.update("issue", { id: a.id, status: "closed", updatedAt: a.updatedAt });
},
),
Add this entry to the mutators object above. Row<typeof issue> describes
the generated issue table; tx.row can return undefined when the row is absent.
yield tx.row(table, { pk })— a point read by primary key.yield tx.query(builder)— a full ad-hoc query (where/orderBy/limit/ joins) evaluating to its rows — always an array, in the query’s order (a root.one()is not unwrapped — take[0]). Build it with the samenewQueryBuilder(schema)your app-def exports.yield tx.all([tx.row(...), tx.row(...)])— fan point reads out. Resolved concurrently on the server, in array order on the client, results returned in the same order on both tiers so the body stays deterministic.
Every read sees the current base plus this transaction’s own staged writes
(read-your-writes) — on the browser engine and in the server’s authoritative
transaction alike. That symmetry is what makes read-dependent writes correct under
rebase: the body replays the intent against whatever state it lands on, not a
stale effect. (If you drive mutators against a Postgres authority instead of the
daemon, point reads (tx.row) work today. Full tx.query support there is
planned.)
This has two consequences:
- Ownership checks can live in the one body.
deleteIssuein the quickstart reads the row and returns early for a non-owner — a no-op locally and in the authoritative run, wherectx.useris the verified principal. - A reading mutator can’t be folded — see high-frequency writes.
The acting principal: ctx.user
Every shared body receives ctx: MutatorCtx — { user }, the authenticated
identity of whoever is writing — as its third argument.
- The client injects its local user: the
user: () => currentUser()option ofcreateRindleClient(re-read per invoke, stable across a rebase re-invoke). - The server injects its authenticated principal — see
sharedCtxin the API server.
The browser does not send ctx.user as a mutation argument. The server supplies
its own identity from verified credentials. A development header alone does not
authenticate a caller.
Use ctx.user for the acting identity. An owner or author argument is untrusted
input. A requested ownership transfer can still be an argument, subject to server
authorization.
The determinism rules
A mutator body re-runs on every rebase, and the server replays it from
(name, args) alone. So the body must be a pure function of
(args, ctx, reads):
- No
Date.now(), noMath.random(), no I/O. Generate ids and timestamps at the callsite and pass them in as args. - No reading component or module state — everything the body needs arrives as
args,
ctx, or ayielded read. - No local-only tables. A mutator replays from
(name, args)on the server, so it can’t depend on private browser rows — usestore.writeLocalfor those. - Normalize inside the body (trim, clamp, default) so both tiers normalize
identical inputs the same way. A helper like
cleanTitlekeeps that rule shared.
If the body throws during the initial browser prediction, the call throws and does not enqueue that mutation. A throw during the authoritative server execution rolls back its transaction; rejection then removes the browser prediction. The full rejection model (hard reject vs. accepted-but-no-op) lives in the API server.
High-frequency writes must be absorbing
app.mutate.<name>.folded(opts, args) collapses a run of same-key calls into one
pending entry. The local view updates on every call — the server sees only the
last (see the browser client
for the mechanics). The constraint lives with the mutator: a folded mutator must
be absorbing — replaying only the last args must equal replaying all of them
(setScore(8) after setScore(5) is just 8). An increment()-style body is
not absorbing and must not be folded. The folded path refuses a mutator that
reads state (yield tx.row / tx.query) by throwing.
Next steps
- The browser client — how predictions apply, rebase, and snap back, plus folded-write mechanics.
- The API server —
sharedApiMutators, server-only authority (policy guards, the raw-SQL escape hatch), and the two rejection shapes. - Synced-app quickstart — the full
shared/app-def.tscontract in context. - Schema & migrations — the generated schema these ops typecheck against.
- Troubleshooting — the ways a mutator goes subtly wrong.