Mutations

`*.mutation.ts` + `*.mutation.server.ts` pairs — atomic writes with typed schemas and auto-optimistic metadata.

A mutation is the write primitive. Every ctx.store.insert, update, and delete inside one mutation runs in a single transaction. If the executor throws, the transaction rolls back and subscribers never observe a partial write.

Declare target metadata in the descriptor so the client can derive optimistic patches for queries whose source points at the same table.

Live — submitting adds the row optimistically (it shows instantly, then the server delta confirms it):

<AutoForm api="app" mutation="todos.create" submitLabel="Add todo" />
<DataTable api="app" query="todos.list" />

Mutation Pair

Descriptor:

// apps/api/mutations/notes.create.mutation.ts
import { defineMutation } from '@voltro/protocol'
import { Schema } from 'effect'

export const createNote = defineMutation({
  name:   'notes.create',
  target: { table: 'notes', op: 'insert' },
  guards: [{ scope: 'notes:write' }],
  input:  Schema.Struct({
    title: Schema.NonEmptyString,
    body:  Schema.String,
  }),
  output: Schema.Struct({
    id: Schema.String,
  }),
})

guards: is what makes this file boot. A wire-exposed mutation must declare exactly one of guards:, openAccess: '<reason>' or internal: true — a descriptor with none of them is refused at boot, naming the file. A write is also where a rubber-stamp guard costs the most, so name the scope the write actually needs rather than one every caller already holds. Full rules: Authorization.

Server executor:

// apps/api/mutations/notes.create.mutation.server.ts
import type { AppContext } from '@voltro/runtime'

export default async (
  input: { title: string; body: string },
  ctx: AppContext,
) => {
  const inserted = await ctx.store.insert('notes', {
    title:    input.title,
    body:     input.body,
    authorId: ctx.request.subject.id,
    tenantId: ctx.request.subject.tenantId,
  })

  return { id: String(inserted['id']) }
}

The descriptor is the wire contract. The .mutation.server.ts file is the server-only implementation.

insert returns the post-image as an untyped Row (Readonly<Record<string, unknown>>), so narrow the field you need (String(...)) instead of asserting it with as string — an assertion silences the compiler without checking anything. To read a row back, use the fluent terminals: ctx.store.select('notes').where('id', id).one() fails with the typed NoRowFound when the row is missing (or when more than one matches), so you never need a hand-written not-found branch; .first() / .maybeOne() return null instead. That fluent builder is string-keyed and yields an untyped Row; for a TYPED single row, pass the database.<table> builder to the store's own terminals — await ctx.store.one(database.notes.where(eq('id', id))) returns the row type with no cast. See single-row terminals.

What The Runtime Does

  1. Decode input with the descriptor schema.
  2. Run mutation plugin interceptors.
  3. Execute the server file inside store.transactional(...).
  4. Encode output with the descriptor schema.
  5. Commit the transaction.
  6. Drain the batched change events so matching query subscriptions receive new snapshots or deltas.

Missing required columns fail LOUD, at the call

ctx.store.insert / upsert / insertIgnore check the payload against the table before the statement runs. A column that is NOT NULL, has no default, and isn't auto-stamped — a business FK like teamId, a plain timestamp() like lastRefreshedAt — must be present, or the write raises a typed TableValidationFailed naming it:

TableValidationFailed: missing required column 'lastRefreshedAt'
  — NOT NULL with no default and not auto-stamped

Without this the omission slips past tsc and boot and surfaces only as a raw dialect SqlError: Failed to execute statement — and only on the INSERT path, so it stays dormant until the first row with no existing cache entry. An upsert / insertIgnore whose payload omits one of its own conflictColumns is named the same way (an absent conflict key can't match its target). The check runs after stamping, so auto-id, tenantId, and audit columns never trip it, and it skips nullable and defaulted columns — the ones you may legitimately omit.

Catch it at COMPILE time — insertRow / upsertRow

The runtime guard above is the backstop. To catch a missing column at compile time, use insertRow / upsertRow — they take the table object (not a string name), so the payload is typed against the table's required columns:

import { insertRow } from '@voltro/database'

// ✗ compile error — lastRefreshedAt is NOT NULL with no default
await insertRow(ctx.store, roadmapEpicStats, { teamId })
// ✓
await insertRow(ctx.store, roadmapEpicStats, { teamId, lastRefreshedAt: new Date() })

InferInsertRow<T> makes a column optional exactly when you may omit it — it's nullable, has a .default(), or is a framework-filled id / tenantId / audit column — and required otherwise. upsertRow's conflictColumns are constrained to the table's own columns, so a typo is a compile error too. The string-keyed ctx.store.insert / upsert still work unchanged; the typed seam is opt-in.

If you adopt it, check your tooling for the old spelling. insertRow replaces store.insert('<tableName>', …), so anything that matches on that string stops matching — a lint rule, a codemod, an architecture test. One team had a test asserting every mutation writes an audit log; it matched store.insert('auditLogs', and migrating to insertRow silently blinded it. Green suite, gap reintroduced. Grep for the old spelling before you migrate, not after.

Partial updates: ctx.store.applyDefined

A partial-update mutation should set only the fields the caller actually sent — not overwrite an omitted field with undefined. Instead of hand-writing if (input.x !== undefined) patch.x = input.x per field, use ctx.store.applyDefined(input, keys):

const execute = async (input: UpdateNote, ctx: AppContext) =>
  ctx.store.update('notes', input.id, ctx.store.applyDefined(input, ['title', 'body', 'dueAt']))

It returns a patch containing only the listed keys whose value is not undefined (a defined falsy value like 0 / '' / false IS kept). Also importable standalone (import { applyDefined } from '@voltro/runtime') for seeds/tests.

Calling From React

import { useMutation } from '@voltro/client'

export default function NewNote() {
  const create = useMutation<{ title: string; body: string }, { id: string }>(
    'app',
    'notes.create',
  )

  return (
    <form onSubmit={async (event) => {
      event.preventDefault()
      const form = new FormData(event.currentTarget)
      await create.mutate({
        title: String(form.get('title')),
        body:  String(form.get('body')),
      })
    }}>
      <input name="title" />
      <textarea name="body" />
      <button disabled={create.pending}>
        {create.pending ? 'Saving...' : 'Save'}
      </button>
    </form>
  )
}

useMutation returns mutate, pending, error, data, plus the chainable optimistic helpers.

Handling the result — onSuccess / onError / notify

Pass a result handler to mutate instead of wrapping every call in try/catch/finally + toasts. pending already replaces the finally:

const create = useMutation('app', 'teams.create')

await create.mutate(input, {
  onSuccess: (team) => setOpen(false),
  notify: { success: t('teams.created'), error: (e) => messageFor(e) },
})

The load-bearing rule: supplying an error handler (onError or notify.error) marks the failure handledmutate then resolves with undefined instead of rejecting, which is what removes the try/catch. With no error handler it rejects exactly as before, so an unhandled failure stays loud. You opt in per call.

notify routes to an app-wide sink you register once — the framework is not bound to any toast library:

import { setMutationNotifier } from '@voltro/client'
setMutationNotifier({ success: (m) => toast.success(m), error: (m) => toast.error(m) })

The callback form is for SINGLE-SHOT writes. A loop or a multi-step sequence relies on the promise throwing to stop. Once the failure is handled the promise resolves, so the loop cheerfully continues past the row that failed:

// WRONG — onError handles the failure, so the loop never stops
for (const row of rows) {
  await create.mutate(row, { onError: (e) => toast.error(messageFor(e)) })
}

// RIGHT — bare mutate rejects, so the sequence aborts where it broke
try {
  for (const row of rows) await create.mutate(row)
} catch (e) {
  toast.error(messageFor(e))
}

The same applies to run on actions.

Idempotency — a retried mutation runs exactly once

The reactive client resends an in-flight mutation after a network blip. Without a guard, "create order" or "charge card" would run twice. useMutation (and useAction) mint a fresh idempotency key per call and attach it to the rpc frame; when idempotency is enabled the server dedupes a repeat of that key — the handler runs once and the retry replays the first result.

Enable it once (this also covers the REST Idempotency-Key header — one switch, both surfaces):

// app.config.ts
export default {
  idempotency: true, // or { ttlMs: 600_000 } — the dedup window (default 24h)
}

For a HIGHER-level guarantee — dedupe a double-click or an offline resend of the same logical action — pass a STABLE key derived from the action's identity, instead of the per-call one:

await placeOrder.mutate(cart, { idempotencyKey: `order:${cart.id}` })

The key is scoped to (tenant, subject, mutation), so one user's key can never replay another's. The replayed result is byte-for-byte the first one — a Date in the output comes back a Date, not a string — because it round-trips through the mutation's output schema. Off by default: with no idempotency config, every call runs.

Auto-Optimistic

The default path is declarative:

defineQuery({
  name: 'notes.list',
  source: 'notes',
  guards: [{ scope: 'notes:read' }],
  input: Schema.Struct({}),
  output: Schema.Array(Note),
})

defineMutation({
  name: 'notes.create',
  target: { table: 'notes', op: 'insert' },
  guards: [{ scope: 'notes:write' }],
  input,
  output,
})

Then the client can stay plain:

const notes = useSubscription('app', 'notes.list', {})
const create = useMutation('app', 'notes.create')

await create.mutate({ title, body })

The client stages an optimistic patch, calls the server, then retires the patch. Server-pushed deltas are still the source of truth.

Retiring the patch has exactly two shapes, and they are not interchangeable:

  • Rollback — only when the write FAILED. The server did not write, so the preview must go, immediately. Nothing else ever rolls a patch back: not a timer, not an elapsed window, not a heuristic.
  • Hand-off — when the server's own state arrives and reflects the write. A delta always supersedes (it is the echo of committed writes); a fresh snapshot supersedes only when it actually moved the base. Base gains the real row and the placeholder goes in the same update, so there is no flash and no optimistic+real duplicate.

If a confirmed write never gets its echo — reactivity is broken for that query's source table — the client re-issues the subscription and keeps the preview, and reports it on the error bus (visible in voltro logs). It does not fall back to a base it knows does not reflect the write: a user watching their saved change disappear will either redo it or plan on a state they believe was not stored.

For special shapes, override the patch:

const create = useMutation('app', 'notes.create').withOptimistic((cache, input) => {
  cache.forTag<ReadonlyArray<{ id: string; title: string }>>('notes.list', (rows) => [
    { id: `temp:${Date.now()}`, title: input.title },
    ...rows,
  ])
})

Use .withoutOptimistic() for effects that should not preview locally.

Nested / path-targeted optimistic

By default a target patches the flat top-level row array a query returns, keyed by id. When a query returns a nested array — a JSON array column (snapshot.projects) or a computed/shaped value — add path (and, if the item key isn't id, by) to patch at item granularity, with no hand-written .withOptimistic reducer:

target: {
  table: 'projectRoadmaps', op: 'update',
  path: 'snapshot.projects',                 // dot-path to the nested array in the value
  identify: (input) => input.projectId,      // which item to patch (default input.id)
}
  • op: 'insert' appends (or order: 'prepend') a new item into the nested array — safe even on a computed query (a path insert targets a KNOWN document, not a blind top-level add).
  • op: 'delete' filters the item out by its key.
  • by overrides the item-key field (default 'id').

Shape the item with shapeItem (not shape). For a nested target, build/patch the item with shapeItem — it is typed to the item of the nested array, not the mutation's output, so current needs no cast:

target: {
  table: 'projectRoadmaps', op: 'update', path: 'snapshot.projects',
  identify: (input) => input.projectId,
  shapeItem: (input, current) => ({ ...current, startDate: input.startDate }),  // `current` IS the item
}

(The flat shape stays bound to the output row — a single field can't be both, so the nested shaper is its own.)

Bulk (multi-item) patches. identify may return an array of ids to patch or delete many items in one mutation — exactly the group-drag / batch-edit where per-item parallel writes used to race:

target: {
  table: 'projectRoadmaps', op: 'update', path: 'snapshot.projects',
  identify: (input) => input.projectIds,                    // ← ARRAY: patch them all
  shapeItem: (input, current) => ({ ...current, shiftedBy: input.delta }),  // each keeps its own key
}

(This works for flat top-level targets too — identify returning an array patches/deletes every matching row.)

Add match to patch only the entries whose current value satisfies a predicate — the guard that stops a patch bleeding across sibling subscriptions sharing a source table:

target: {
  table: 'projectRoadmaps', op: 'update', path: 'snapshot.projects',
  identify: (i) => i.projectId,
  match: (value, input) => value.id === input.roadmapId,   // only THIS roadmap's subscription
}

path, by, match, and shapeItem are browser-safe descriptor data (a dot-path string + pure functions) — the same discipline as identify/shape.

Declared relations — a junction saved in the same mutation

A form with a multi-reference field (assigned stores, tags, members) writes a JUNCTION table beside the row. Declare that on the write target and the framework reconciles the links INSIDE the mutation's transaction — no hand-written junction code in the executor, and a failure rolls the whole write back:

export const employeesUpdate = defineMutation({
  name: 'employees.update',
  input: EmployeesUpdateInput,   // carries assignedStores: string[]
  output: Employee,
  target: {
    table: 'employees',
    op: 'update',
    relations: {
      assignedStores: {
        junction: 'employee_assigned_stores',
        anchorColumn: 'employeeId',   // the junction reference() pointing at `employees`
        targetColumn: 'storeId',      // the junction's other reference()
      },
    },
  },
})

After the executor succeeds, input.assignedStores is reconciled against the junction via the diff-based link writer (store.relationLinks): missing rows inserted, surplus rows deleted, unchanged rows untouched — so reactive subscriptions on the junction see one change per changed row.

The semantics worth knowing: an ABSENT input field leaves the links untouched — absent is not empty; an empty array is the explicit "clear them all". The row id comes from the executor's output.id, falling back to input.id. The link writes go through ctx.store, so undo capture and cross-table rules see them like any other write.

The same declaration drives the optimistic update

A junction change used to reach the browser only with the server delta — so on one submit the renamed title flipped immediately and the assigned stores sat on their old value until the roundtrip landed. It does not any more: useMutation reconciles the junction rows of every subscription sourced on junction the moment the mutation is sent, against the same input[field] the server will write.

It is a diff, not a redraw: a surviving link keeps its own row (and its real id), a surplus link disappears, and only a genuinely new link is a staged optimistic row. The patches ride the ordinary optimistic lane — reverted if the mutation fails, kept after it succeeds until the server data actually moves. Nothing is on a timer.

Client-side the anchor id is input.id; for an insert it is the same optimistic id the new row was stamped with, since the server's output.id is not knowable before the response arrives.

Why you state the two columns. The optimistic patch runs in the BROWSER, and the browser cannot import your db/ schema — @voltro/database is server-only by construction — so the junction's two reference() columns cannot be derived there. anchorColumn is the one pointing at the target's own table; targetColumn is the other. They are not taken on trust: before it writes, the server compares your declaration against the junction's real reference columns and refuses, naming the correct pair, if they disagree. A self-junction (both columns referencing one table) is still refused by name, never guessed.

Typed Errors

import { Schema } from 'effect'

class NoteQuotaExceeded extends Schema.TaggedError<NoteQuotaExceeded>()('NoteQuotaExceeded', {
  limit: Schema.Number,
}) {}

export const createNote = defineMutation({
  name: 'notes.create',
  target: { table: 'notes', op: 'insert' },
  guards: [{ scope: 'notes:write' }],
  input,
  output,
  error: NoteQuotaExceeded,
})

Throw a matching error from the server file; the client can narrow on _tag.

Matching typed errors on the client

Tagged errors round-trip structurally over the wire — the caught value carries _tag plus every declared field as real properties (and instanceof works, same Schema class both ends). You do not need to parse the error message string.

Inside Effect, use Effect.catchTag('NoteQuotaExceeded', …). In a React try/catch (outside Effect, where catchTag isn't available and the decoded value may be a plain object, not a class instance), match with errorTag(err) — the dependency-free tag reader @voltro/protocol ships (it reads what toRpc writes):

import { errorTag } from '@voltro/protocol'

try {
  await createNote(input)
} catch (err) {
  if (errorTag(err) === 'NoteQuotaExceeded') {
    // err.limit is the declared field — read it directly, no regex
  }
}

Trace id for debugging. An error caught from useMutation / useAction carries a non-enumerable __voltroTraceId — the bridge to the server logs for that exact call:

const traceId = (err as { __voltroTraceId?: string }).__voltroTraceId
// → `voltro logs --trace <traceId>` to see the server-side span

errorTag lives in @voltro/protocol rather than in the client for a reason worth knowing before you decide where your own error handling goes: _tag is a wire concept, and protocol owns the wire. So a shared error handler in a package that has no business depending on @voltro/client — a UI kit, an i18n layer — can read a tag without taking that dependency. instanceof is the thing that does NOT survive the wire: what arrives in the browser was decoded from JSON and never constructed, so match on the tag, not on the class.

Exhaustive matching with the generated matchError. Codegen emits a per-app matchError (plus AppError / AppErrorTag) into rpcGroup.generated.ts, derived by reference from every descriptor's error: schema + your plugins' cross-cutting errors — so there's no hand-maintained tag list to drift out of date (a dead/renamed tag is a compile error):

import { matchError } from './rpcGroup.generated'

const message = matchError(err, {
  NoteQuotaExceeded: (e) => `Limit ${e.limit} reached`,   // e is typed
  ScopeError: (e) => `Missing ${e.required}`,
}, () => 'Something went wrong')

Cross-table business rules — .rule()

A typed error is declared on ONE mutation. A rule is declared on a TABLE: an invariant the runtime enforces on EVERY mutation that writes that table, no matter which one did the write. Declare it in the schema with .rule(name, predicate):

import { table, id, integer, eq } from '@voltro/database'
import { database } from '../database/schema'

export const invoices = table('invoices', {
  id:    id(),
  total: integer(),
}).rule(
  'totalMatchesLineItems',
  async (row, { store }) => {
    const items = await store.query(
      database.lineItems.where(eq('invoiceId', row.id)).descriptor,
    )
    const sum = items.reduce((acc, l) => acc + Number(l.amount), 0)
    return sum === row.total || { params: { computed: sum, declared: row.total } }
  },
)

The predicate receives the post-write row and a context whose store is the SAME transactional store the mutation wrote through — so a cross-table read shares the write's MVCC snapshot and cannot race it. It runs INSIDE the mutation transaction, after the write and before commit, and it is dialect-neutral (the same predicate is correct on all four dialects — no per-dialect code). Return true (or nothing) when the invariant holds; return false or a RuleViolationDetail ({ params?, field?, message? }) to signal a violation for THIS row.

Unlike .check() — a single-row SQL CHECK the DATABASE enforces as DDL — a rule is a PREDICATE the runtime evaluates, so it can read OTHER tables. Use .check() when the database itself must guarantee a single-row constraint; reach for .rule() for a cross-table invariant (an invoice total matching its line items, a booking not exceeding a resource's capacity).

The violation is a typed error — automatically

A violated error-severity rule rolls the whole mutation back and fails with the typed, wire-preserved BusinessRuleViolation. You do not declare it on the mutation's error: — the runtime auto-merges it into every mutation's error union at the wire boundary, exactly like ScopeError. A rule declared on a SCHEMA table can fail ANY mutation that writes that table, so no single descriptor could know to declare it; the auto-merge is what keeps the violation a typed error the client decodes by _tag rather than an untyped defect.

Match it on the client the same way as any typed error:

import { errorTag } from '@voltro/protocol'

try {
  await createInvoice(input)
} catch (err) {
  if (errorTag(err) === 'BusinessRuleViolation') {
    // err.rule — the rule name; err.params — the offending values; err.field — the pointer
  }
}

BusinessRuleViolation carries rule (the declared name), optional params (i18n params for the offending values), an optional field pointer, and severity (always 'error' on the wire — a warning-severity rule never reaches the client as an error).

severity: 'warning' — log without blocking

export const invoices = table('invoices', { id: id(), total: integer() }).rule(
  'totalMatchesLineItems',
  async (row, { store }) => true,
  { severity: 'warning' },
)

A warning-severity rule LOGS and audits the violation but lets the write commit — useful while backfilling data that does not yet satisfy a newly-added invariant. The default is 'error' (roll back).

Rules run on voltro dev and voltro serve through the same mutation runner, so the two boot paths cannot disagree about whether a rule fires. Inserts and updates on your own tables are re-validated; a delete is not (there is no post-write row to check).

internal: true — off the wire entirely

Every discovered *.mutation.ts / *.query.ts / *.action.ts / *.stream.ts gets a route on the WebSocket — which is why each one has to declare who may call it, and why an app carrying an undecided procedure does not boot at all. internal: true is the third answer to that question, beside guards: and openAccess:: there is no wire surface to make a decision about, because the procedure never gets a route. (publicApi and exposeAsTool go the other way and opt IN to wider surfaces — which is why neither combines with internal.)

Reach for it when the caller is other server code — a workflow step, a schedule, another executor — and never a browser:

export const createFromAction = defineMutation({
  name: 'auditLog.createFromAction',
  input: Schema.Struct({ actorId: Schema.String, eventType: Schema.String }),
  output: Schema.Void,
  internal: true,
})

It is not emitted into rpcGroup.generated.ts, and neither voltro dev nor voltro serve registers a route — the tag is unroutable over /rpc and the WebSocket. Server code calls it by importing its executor directly.

A naming convention is not a boundary. One app had grown 18 procedures named *Internal, meaning "only other server code calls this"; all 18 were in the client group, and one of them accepted actorId / actorEmail / actorType from the caller and wrote an audit row. No guard, zero callers, reachable by anyone logged in. If the only thing keeping a procedure off the wire is that nobody wrote a client call for it, it is on the wire — the same reasoning as .serverOnly() on a column, one level up.

That app is why the boot gate exists: all 18 declared no access decision, so today it does not start until each of them says guards:, openAccess: or internal: true. The gate turns "reachable and nobody looked" into a refusal naming every file — but it only forces the question, it cannot answer it, and internal: true is the right answer only when no browser is meant to call the procedure at all.

It is not a substitute for a guard. An internal procedure still runs with whatever authority its caller has. This removes the wire surface, not the need to check who is asking; voltro doctor's authz scan still covers it.

It cannot be combined with publicApi or exposeAsTool. Those add a REST route and an agent tool respectively — opt-ins to a different surface — so a procedure carrying both would be unreachable from your own client and reachable from the internet. That combination throws where it is declared:

auditLog.createFromAction: `internal: true` cannot be combined with `publicApi`.

Neither silent resolution would be right: dropping the REST route breaks a live endpoint invisibly, and keeping it defeats the flag. Drop internal: true if the wider surface is intended, or remove the annotation if it is not.

The flag also removes the procedure from voltro dev's inspect invoker, so the devtools "invoke" panel will not list it. That is deliberate — an internal procedure is the one most likely to carry no guard, since "only server code calls this" is the reason people write them.

When Not To Use A Mutation

  • External I/O. Use an action or workflow.
  • Progress output. Use a stream.
  • Reads. Use a query.
  • Long-running durable work. Use a workflow.