Skip to content
Guides contents

GuidesSync & optimistic writes

Isomorphic mutators

Define shared write logic, run it as a browser prediction and server transaction, and understand replay requirements.

View as Markdown

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 (renders ON CONFLICT DO NOTHING server-side). The isomorphic twin of if (!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 same newQueryBuilder(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. deleteIssue in the quickstart reads the row and returns early for a non-owner — a no-op locally and in the authoritative run, where ctx.user is 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 of createRindleClient (re-read per invoke, stable across a rebase re-invoke).
  • The server injects its authenticated principal — see sharedCtx in 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):

  1. No Date.now(), no Math.random(), no I/O. Generate ids and timestamps at the callsite and pass them in as args.
  2. No reading component or module state — everything the body needs arrives as args, ctx, or a yielded read.
  3. No local-only tables. A mutator replays from (name, args) on the server, so it can’t depend on private browser rows — use store.writeLocal for those.
  4. Normalize inside the body (trim, clamp, default) so both tiers normalize identical inputs the same way. A helper like cleanTitle keeps 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 serversharedApiMutators, server-only authority (policy guards, the raw-SQL escape hatch), and the two rejection shapes.
  • Synced-app quickstart — the full shared/app-def.ts contract in context.
  • Schema & migrations — the generated schema these ops typecheck against.
  • Troubleshooting — the ways a mutator goes subtly wrong.