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 handledrun 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 undo never replaces the original error, and never stops the remaining undos. Cleanup failures come back in compensationFailures, 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 defineStream and useAgentStream.