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' },
input: Schema.Struct({
title: Schema.NonEmptyString,
body: Schema.String,
}),
output: Schema.Struct({
id: Schema.String,
}),
})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.
Auto-Optimistic
The default path is declarative:
defineQuery({
name: 'notes.list',
source: 'notes',
input: Schema.Struct({}),
output: Schema.Array(Note),
})
defineMutation({
name: 'notes.create',
target: { table: 'notes', op: 'insert' },
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 removes the patch when the server result or failure arrives. Server-pushed deltas are still the source of truth.
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.
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' },
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/client ships:
import { errorTag } from '@voltro/client'
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 spanExhaustive 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')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.