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 ofguards:,openAccess: '<reason>'orinternal: 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
- Decode
inputwith the descriptor schema. - Run mutation plugin interceptors.
- Execute the server file inside
store.transactional(...). - Encode
outputwith the descriptor schema. - Commit the transaction.
- 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-stampedWithout 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.
insertRowreplacesstore.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 matchedstore.insert('auditLogs', and migrating toinsertRowsilently 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 handled — mutate 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 (ororder: '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.byoverrides 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 spanerrorTag 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.