Actions
`*.action.ts` + `*.action.server.ts` pairs — unary server calls for external I/O and non-transactional work.
An action is a typed unary RPC that runs outside the mutation transaction wrapper. Use it for external I/O and request-scoped work: HTTP calls, emails, signed upload URLs, AI calls that return one value, or kicking off a workflow.
Actions can read or write through ctx.store, but those writes are not grouped into one automatic transaction and they do not drive client auto-optimistic updates. If the main purpose is an atomic database write, use a mutation. If the client should receive incremental elements while work is running, use a stream.
Live — a unary action: call it, get one typed answer back. No transaction, no optimistic patch:
const echo = useAction('app', 'demo.echo')
await echo.run({ message }) // one call → one typed result
Action Pair
Descriptor:
// apps/api/actions/support.ping.action.ts
import { defineAction } from '@voltro/protocol'
import { Schema } from 'effect'
export const pingExternal = defineAction({
name: 'support.ping',
input: Schema.Struct({ url: Schema.String }),
output: Schema.Struct({
status: Schema.Number,
durationMs: Schema.Number,
}),
})Server executor:
// apps/api/actions/support.ping.action.server.ts
import { HttpClient, HttpClientRequest } from '@effect/platform'
import { Effect } from 'effect'
export default (input: { url: string }) =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const startedAt = Date.now()
const response = yield* client.execute(HttpClientRequest.get(input.url))
return {
status: response.status,
durationMs: Date.now() - startedAt,
}
})The descriptor file is safe for client-side discovery. The .action.server.ts file contains the server-only code and can return a plain value, a Promise, or an Effect.
Calling From React
import { useAction } from '@voltro/client'
const ping = useAction<{ url: string }, { status: number; durationMs: number }>(
'app',
'support.ping',
)
const onClick = async () => {
const result = await ping.run({ url: 'https://example.com/health' })
console.log(result.status)
}useAction returns run, pending, error, and lastResult.
Handling the result — onSuccess / onError / notify
run takes the same options bag as a mutation's mutate,
so a one-shot action does not need a try/catch/finally + toast wrapper —
pending already replaces the finally:
const invite = useAction('app', 'invites.send')
await invite.run({ email }, {
onSuccess: (out) => toast.success(`sent ${out.id}`),
onError: (e) => toast.error(readError(e)),
})The load-bearing rule: supplying an error handler (onError or
notify.error) marks the failure handled — run then resolves with
undefined instead of rejecting, which is what removes the try/catch. With no
options it rejects exactly as before, so an unhandled failure stays loud.
The callback form is for SINGLE-SHOT calls. A loop or a multi-step sequence relies on the promise throwing to stop; once the failure is handled the promise resolves and the sequence runs on past the step that broke:
// WRONG — onError handles the failure, so the loop never stops
for (const email of emails) {
await invite.run({ email }, { onError: (e) => toast.error(readError(e)) })
}
// RIGHT — bare run rejects, so the sequence aborts where it broke
try {
for (const email of emails) await invite.run({ email })
} catch (e) {
toast.error(readError(e))
}Multi-step sequences — useSequence
The rule above says a loop keeps its try/catch, and that left multi-step
writes as the only verbose thing on the write path — while being the hardest
case, not the easiest. useSequence runs the steps in order, gives the whole
sequence one onError, and lets each step say how to undo itself:
const seq = useSequence({ onError: (e) => toast.error(readError(e)) })
const result = await seq.run(
sequence()
.step('upload', () => upload.run({ file }), {
undo: (created) => removeObject.run({ id: created.id }),
})
.step('attach', (c) => createAttachment.run({ refId: c.upload.id })),
)
if (result.ok) setAttachmentId(result.data.attach.id)Each step's context is typed and accumulates, so a fourth step can read the
first step's result by name. run resolves with a discriminated result rather
than rejecting — same "handled" semantics as useMutation/useAction.
undo receives its own step's result, which is the point: the id you just
created is what you need to delete it again. Undos run in reverse for the steps
that already succeeded.
This is not a transaction, and the difference matters. After createTicket
returns, the ticket exists in Jira; nothing the browser does un-creates it, it
can only issue a delete and hope. And the compensation runs in the tab — close
it, lose the network, or navigate away mid-rollback and the remaining undos
never happen. Two rules follow, and the primitive enforces both:
- the step that failed is never compensated (it may or may not have had an effect — undoing it is a guess, and a wrong guess deletes something else);
- a failing
undonever replaces the original error, and never stops the remaining undos. Cleanup failures come back incompensationFailures, so an orphan is something you can see rather than something you find later.
Undos are independent, so keep them idempotent — or say they overlap. Every
succeeded step's undo runs, and the runner cannot tell whether two of them
reverse the same thing. If deleteJiraDraftTicket deletes the issue and
discards the draft, the earlier discardDraft undo runs on something already
gone. Harmless when discarding is idempotent; a real defect for a refund or a
cancellation email. Declare the overlap instead of relying on luck:
sequence()
.step('draft', () => createDraft.run(input), { undo: (d) => discardDraft.run({ id: d.id }) })
.step('jira', (c) => createTicket.run({ draftId: c.draft.id }), {
undo: (t, ctx) => deleteJiraDraftTicket.run({ key: t.key, draftId: ctx.draft.id }),
covers: ['draft'],
})An undo receives (result, ctx) — its own result and the context up to that
step, the same context covers and when see. An inverse that needs an id from
an earlier step (here deleteJiraDraftTicket wants both the jira key and the
draftId) reads it from ctx rather than the step re-returning it just so the
undo can reach it. A later step's result is not in ctx: it is rolled back
before this one, so reading it would be reading something already reversed.
If the covering undo fails, the covered steps are neither run nor claimed —
whether the cascade got that far is unknown, and both guesses are wrong. They
come back in compensationUncertain so you can reconcile.
One optional step is fine; a loop or a real branch is not. when skips a
step, and a skipped step contributes undefined to the context (the type says
so) and gets no undo:
sequence()
.step('save', () => save.run(input))
.step('summary', (c) => scheduleSummary.run({ id: c.save.id }), {
when: (c) => c.save.changed,
})That is deliberately narrow: it catches "linear except for one if", which is a
different shape from a loop. Loops and full branches still keep their
try/catch.
When the rollback has to survive a closed tab, this is the wrong tool. Put the sequence in a workflow: the engine owns retries and compensation there, and a crash resumes instead of leaking.
Action vs Mutation vs Stream
| Need | Use |
|---|---|
| Atomic database write with rollback | Mutation |
| External I/O or one-shot server call | Action |
| Progressive server-to-client output | Stream |
| Durable multi-step work | Workflow |
| Client-side optimistic preview | Mutation with target metadata |
Database Writes
Actions are not wrapped in the mutation transaction. A throw does not roll back previous writes or external side effects.
export default async (input, ctx) => {
await ctx.store.insert('auditLogs', {
message: input.message,
actorId: ctx.request.subject.id,
})
await sendExternalWebhook(input)
return { ok: true }
}Use this shape only when that ordering is acceptable. If several writes must commit or roll back together, move them to a mutation. If the external side effect must survive retries, model the operation as a workflow.
Typed Errors
Actions use the same error: Schema.Union(...) descriptor field as mutations. Throwing a matching tagged error reaches the client as a typed failure from run(...).
See Error handling for the pattern.
Anti-Patterns
- Actions used as reads. Use a query if the client wants cached reactive data.
- Actions used for atomic writes. Use a mutation so subscribers never see partial state.
- Actions used for progress feeds. Use
defineStreamanduseAgentStream.