API · Durable
A cohesive order-fulfillment domain that exercises the whole durable surface — a workflow with a human-approval gate, an event trigger, a signal-injecting mutation, a cron, a subscriber, an aggregate, and a startup hook. Zero-infra boot.
The template that exercises the framework's durable-execution surface end-to-end. Instead of a one-table CRUD reference, it ships a cohesive order-fulfillment domain: placing an order emits a domain event, a trigger starts a durable workflow, the workflow reserves stock → sleeps → parks on a human-approval gate, and a second mutation injects the approval signal that wakes it. Around that flow it wires a nightly cron, a table subscriber, a materialized aggregate, and a run-once startup hook — every durable / scheduled / reactive primitive in one app. It boots with zero infrastructure (store: 'memory'). Template id: api-durable.
Scaffold
voltro create-project acme --api=api-durableNo env or services required. store: 'memory' runs the in-process durable workflow engine (replay within one process). Switch app.config.ts to store: 'postgres' (and docker compose up) when you want durable state across restarts + multi-instance cluster workflow runners.
What ships
apps/acme/api/ # dir named by the app, not the template
├── app.config.ts # type:api, store:'memory', defineEnv
├── package.json
├── tsconfig.json
├── README.md
├── database/
│ └── schema.ts # actors + tenants (core) + an orders table
├── mutations/
│ ├── orders.place.mutation.ts # descriptor — insert + emit order.placed
│ ├── orders.place.mutation.server.ts # executor — tenant guard + post-commit emit
│ ├── orders.approve.mutation.ts # descriptor — the human-approval surface
│ └── orders.approve.mutation.server.ts # executor — inject the approval signal
├── triggers/
│ └── order.placed.trigger.tsx # event order.placed → start orders.fulfill
├── workflows/
│ ├── order.fulfill.workflow.tsx # descriptor — browser-safe contract
│ └── order.fulfill.workflow.server.tsx # executor — step → sleep → awaitSignal → step
├── schedules/
│ └── nightlyReport.cron.tsx # cron 0 2 * * * (UTC) — per-status tally
├── subscribers/
│ └── orderChanges.subscribe.ts # post-commit reaction to every orders write
├── aggregates/
│ └── orderStats.aggregate.ts # materialized per-tenant/per-status roll-up
└── startup/
└── warm.startup.tsx # run-once boot hook + onShutdown teardownEvery primitive is auto-discovered by file convention — nothing is registered in app.config.ts. Compared to api-backend (the smallest CRUD reference), this template adds the workflow / trigger / schedule / subscriber / aggregate / startup conventions on top of the same descriptor-split mutation shape.
The end-to-end flow
- Place an order →
orders.placeinserts the row, then emitsorder.placedafter the transaction commits — so a rollback never starts a workflow for an order that doesn't exist. - The trigger fires →
order.placed.trigger.tsxstarts the durableorders.fulfillworkflow with{ orderId, tenantId }. - The workflow runs durably → reserves stock (with saga-style compensation), waits a journaled delay, then parks on
awaitSignal('approval'). - A human approves →
orders.approvere-derives the run's deterministic executionId and injects theapprovalsignal; the parked run wakes (sub-second) and either ships or marks the order rejected.
Meanwhile the subscriber logs every orders write, the aggregate keeps a live per-status tally, the schedule reports nightly, and the startup hook holds a process-lifetime resource.
Database
The schema declares the two framework core tables plus an orders table carrying the order lifecycle:
// database/schema.ts
import {
databaseHandle, id, integer, table, text, timestamp, type InferRow,
} from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy'
// Core tables — required by the audit / tenant mixins.
export const actors = table('actors', {
id: id(), kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
displayName: text().nullable(), createdAt: timestamp().default('now'),
})
export const tenants = table('tenants', { id: id(), name: text(), createdAt: timestamp().default('now') })
// Application table — the order-fulfillment domain.
export const orders = table('orders', {
id: id({ prefix: 'order' }),
// The fulfillment lifecycle. `oneOf` narrows the row type to the literal
// union AND emits a cross-dialect CHECK constraint.
status: text().oneOf(['placed', 'reserved', 'approved', 'rejected', 'shipped']).default('placed'),
customerName: text(),
amountCents: integer(),
})
.with(tenant()) // pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
.reactive() // subscriber + live subscriptions wake on every write to orders
export type Order = InferRow<typeof orders>
export const database = databaseHandle({ actors, tenants, orders })tenant() makes the table tenant-scoped: the runtime AND-merges eq('tenantId', subject.tenantId) into every subscription so cross-tenant reads can't leak. .reactive() opts the table into the matcher engine so the orderChanges subscriber and any live query fire on every mutation write. See multi-tenancy.
Place — mutation that emits a domain event
orders.place is the entry point. The descriptor declares the optimistic target + typed error; the executor writes the row, guards the tenant, and emits order.placed after commit.
// orders.place.mutation.ts — descriptor (browser-safe)
import { defineMutation } from '@voltro/protocol'
import { TenantMismatch } from '@voltro/plugin-multitenancy/guard'
import { Schema } from 'effect'
export const placeOrder = defineMutation({
name: 'orders.place',
// Auto-optimistic: every query whose `source: 'orders'` matches gets a
// placeholder row prepended; the server delta replaces it on commit.
target: {
table: 'orders', op: 'insert',
shape: (input: { tenantId: string; customerName: string; amountCents: number }) => ({
status: 'placed', customerName: input.customerName,
amountCents: input.amountCents, tenantId: input.tenantId,
}),
},
input: Schema.Struct({
tenantId: Schema.String,
customerName: Schema.NonEmptyString,
amountCents: Schema.Int.pipe(Schema.greaterThan(0)),
}),
output: Schema.Struct({
id: Schema.String, status: Schema.String, customerName: Schema.String,
amountCents: Schema.Number, tenantId: Schema.String,
}),
error: TenantMismatch,
})// orders.place.mutation.server.ts — executor (default export)
import { assertOwnTenant } from '@voltro/plugin-multitenancy/guard'
import type { AppContext } from '@voltro/runtime'
const execute = async (
input: { tenantId: string; customerName: string; amountCents: number },
ctx: AppContext,
) => {
// Cross-tenant write guard — reject a caller spoofing input.tenantId.
assertOwnTenant(input.tenantId, ctx.request.subject)
// Framework auto-injects an `order_…` id + the tenant/audit columns.
const row = await ctx.store.insert('orders', {
status: 'placed', customerName: input.customerName,
amountCents: input.amountCents, tenantId: input.tenantId,
})
// Post-commit fan-out → the `order.placed` trigger starts the
// `orders.fulfill` workflow. Guard `ctx.events` so a unit-test ctx
// without it doesn't throw.
await ctx.events?.emit('order.placed', {
orderId: row['id'] as string, tenantId: input.tenantId,
})
return {
id: row['id'] as string, status: row['status'] as string,
customerName: row['customerName'] as string,
amountCents: row['amountCents'] as number, tenantId: row['tenantId'] as string,
}
}
export default executeTenantMismatch is imported from the browser-safe @voltro/plugin-multitenancy/guard subpath, NOT the package root — the root re-exports the schema mixin, which would drag @voltro/database into the client rpcGroup bundle. ctx.events.emit(...) is post-commit safe: the event is recorded and fans out only once the row is durably written.
Event trigger — start a workflow from a domain event
The trigger maps the order.placed event to a workflow start. It's auto-discovered (*.trigger.tsx); no app.config.ts wiring. See event triggers.
// triggers/order.placed.trigger.tsx
import { defineEventTrigger } from '@voltro/runtime'
export default defineEventTrigger<
{ orderId: string; tenantId: string }
>({
event: 'order.placed',
workflow: 'orders.fulfill',
// Dedup deliveries: a re-delivered event (crash + replay) with the same
// key short-circuits the second start.
idempotencyKey: (event) => `order.placed:${event.data.orderId}`,
})The event payload IS the workflow payload here (both { orderId, tenantId }), so no payload mapper is needed — pass one when the shapes differ.
Durable workflow — step → sleep → awaitSignal → step
Like the rpc primitives, a workflow is two files paired by basename: a browser-safe *.workflow.tsx descriptor (the workflow({...}) contract — schema + idempotency only) and a server-only *.workflow.server.tsx executor. See workflows.
// workflows/order.fulfill.workflow.tsx — descriptor (browser-safe)
import { workflow } from '@voltro/workflow/define'
import { Schema } from 'effect'
export const FulfillOrder = workflow({
name: 'orders.fulfill',
payload: {
orderId: Schema.String,
tenantId: Schema.String,
},
success: Schema.Struct({
orderId: Schema.String,
outcome: Schema.Literal('shipped', 'rejected'),
}),
// Dedup concurrent starts for the same order — after a crash + replay the
// trigger could fan out twice; the same key collapses them to one run.
idempotencyKey: ({ orderId }) => `fulfill:${orderId}`,
})The descriptor imports workflow from the browser-safe @voltro/workflow/define subpath so the codegen can value-import it for the client; the executor imports step / sleep / awaitSignal from the full @voltro/workflow.
// workflows/order.fulfill.workflow.server.tsx — executor (server-only)
import { step, sleep, awaitSignal, withCompensation } from '@voltro/workflow'
import { Effect, Schema } from 'effect'
import type { AppContext } from '@voltro/runtime'
const Decision = Schema.Struct({ approved: Schema.Boolean })
const buildExecute = (ctx: AppContext) =>
(payload: { orderId: string; tenantId: string }, _executionId: string) =>
Effect.gen(function* () {
const { orderId } = payload
// 1. Reserve stock. `withCompensation` registers an undo that runs
// only if the workflow as a whole fails after this point.
yield* withCompensation(
step({
name: 'reserve-stock',
input: { orderId },
success: Schema.Struct({ orderId: Schema.String }),
execute: Effect.tryPromise({
try: async () => {
await ctx.store.update('orders', orderId, { status: 'reserved' })
return { orderId }
},
catch: (error) => error as never,
}),
}),
// Saga rollback: release the reservation on whole-workflow failure.
() =>
Effect.promise(() =>
ctx.store.update('orders', orderId, { status: 'placed' }).then(() => undefined),
),
)
// 2. Durable delay. The wake is journaled — survives a restart.
yield* sleep({ name: 'settle-window', duration: '5 seconds' })
// 3. Human-approval gate. The run parks here until an external caller
// sends a matching `approval` signal; the parsed payload is
// journaled, so a post-signal replay returns it instantly.
const decision = yield* awaitSignal(ctx, {
name: 'approval',
schema: Decision,
// pollIntervalMs defaults to 200 (backoff → 5s); timeoutMs 24h.
})
if (!decision.approved) {
yield* step({
name: 'mark-rejected',
input: { orderId },
success: Schema.Struct({ orderId: Schema.String }),
execute: Effect.tryPromise({
try: async () => {
await ctx.store.update('orders', orderId, { status: 'rejected' })
return { orderId }
},
catch: (error) => error as never,
}),
})
return { orderId, outcome: 'rejected' as const }
}
// 4. Approved → ship.
yield* step({
name: 'ship',
input: { orderId },
success: Schema.Struct({ orderId: Schema.String }),
execute: Effect.tryPromise({
try: async () => {
await ctx.store.update('orders', orderId, { status: 'shipped' })
return { orderId }
},
catch: (error) => error as never,
}),
})
return { orderId, outcome: 'shipped' as const }
})
export default buildExecuteThree durable primitives in one body: each step() is journaled (replayed from its cached result on a crash, never re-run); sleep is a journaled delay that survives a restart; awaitSignal parks the run until an external decision arrives. withCompensation adds a saga-style finalizer that runs only if the whole workflow later fails — releasing the reservation so a downstream failure doesn't strand held stock. The default export is a factory (ctx) => execute; the CLI calls workflow.toLayer(execute) so the executor closes over the live AppContext.
Approve — inject the signal that wakes the parked run
orders.approve is the human-approval surface. It pokes the workflow rather than writing a row, so it declares no optimistic target.
// orders.approve.mutation.ts — descriptor (browser-safe)
import { defineMutation } from '@voltro/protocol'
import { Schema } from 'effect'
export const approveOrder = defineMutation({
name: 'orders.approve',
input: Schema.Struct({
orderId: Schema.String,
tenantId: Schema.String,
approved: Schema.Boolean,
}),
output: Schema.Struct({
orderId: Schema.String,
eventId: Schema.String,
}),
})// orders.approve.mutation.server.ts — executor
import { assertOwnTenant } from '@voltro/plugin-multitenancy/guard'
import type { AppContext } from '@voltro/runtime'
const execute = async (
input: { orderId: string; tenantId: string; approved: boolean },
ctx: AppContext,
) => {
assertOwnTenant(input.tenantId, ctx.request.subject)
if (!ctx.workflows) {
throw new Error('orders.approve: workflow runtime is not available in this context')
}
// Deterministic executionId for the running fulfillment workflow — same
// payload the `order.placed` trigger started it with.
const executionId = await ctx.workflows.executionId('orders.fulfill', {
orderId: input.orderId,
tenantId: input.tenantId,
})
const { eventId } = await ctx.workflows.signal(
{ executionId },
'approval',
{ approved: input.approved },
)
return { orderId: input.orderId, eventId }
}
export default executeBecause the workflow's idempotencyKey (fulfill:<id>) makes its executionId a deterministic function of the payload, the approve mutation re-derives it from { orderId, tenantId } and addresses the signal by executionId — no need to track the run handle from the place mutation. ctx.workflows.signal(...) writes a signal-sent row to the run's events log; the workflow's awaitSignal('approval') poll picks it up and parses it against its { approved: boolean } schema.
Schedule — a nightly cron
A single-file *.cron.tsx schedule runs periodic work. timezone is mandatory; the cron is validated at boot. See scheduling.
// schedules/nightlyReport.cron.tsx
import { defineSchedule } from '@voltro/runtime'
import { count, eq } from '@voltro/database'
import { database } from '../database/schema'
export default defineSchedule({
name: 'nightlyReport',
cron: '0 2 * * *', // 02:00 every day
timezone: 'UTC',
description: 'Logs a per-status order tally each night.',
handler: async (ctx) => {
// Tally placed-but-not-yet-shipped orders as a simple health signal.
const open = await ctx.app.store.query(
database.orders.where(eq('status', 'placed')).aggregate({ total: count() }).descriptor,
)
const total = (open[0] as { total?: number } | undefined)?.total ?? 0
console.info(`[nightlyReport] fired for ${ctx.scheduledAt.toISOString()} — ${total} order(s) still 'placed'`)
},
// optional, all default-safe:
onOverlap: 'skip',
maxRuntimeMs: 5 * 60_000,
})The handler is "a mutation the clock invokes" — ctx.app is the same AppContext shape a mutation gets. Zero-config coordination: on store: 'memory' it runs single-process; on a multi-instance SQL store the framework advisory-lock coordinates so exactly one replica fires each tick. For durable multi-step work, prefer the workflow: { name, payload } form over a handler.
Subscriber — react to every write post-commit
A single-file *.subscribe.ts reacts to table writes after they commit. See subscribers.
// subscribers/orderChanges.subscribe.ts
import { defineSubscriber } from '@voltro/runtime'
export default defineSubscriber({
table: 'orders',
on: 'any', // 'insert' | 'update' | 'delete' | 'any' | a list
handler: async (event, ctx) => {
const row = (event.new ?? event.old) as { id?: string; status?: string } | null
ctx.log.info('order changed', {
op: event.op,
id: row?.id,
status: row?.status,
})
},
})It fires AFTER commit (the row IS written), best-effort + fire-and-forget — a throw logs and the next event still arrives; it can't back-pressure the change stream. event.new is present on insert + update (null on delete); event.old on update + delete (null on insert). For crash-safe multi-step reactions, have the subscriber start a workflow instead.
Aggregate — a materialized roll-up
A *.aggregate.ts is a pre-defined query the framework refreshes on a schedule, caches, and serves through ctx.aggregates.<name>.read(...). See aggregates.
// aggregates/orderStats.aggregate.ts
import { defineAggregate } from '@voltro/runtime'
import { column, count } from '@voltro/database'
import { Schema } from 'effect'
import { database } from '../database/schema'
export const OrderStat = Schema.Struct({
tenantId: Schema.String,
status: Schema.String,
orders: Schema.Number,
})
export type OrderStat = Schema.Schema.Type<typeof OrderStat>
export default defineAggregate({
name: 'orderStats',
refresh: '1m', // re-run every minute (interval shorthand)
output: OrderStat,
build: async (ctx) => {
const rows = await ctx.store.query(
database.orders
.groupBy(['tenantId', 'status'])
// Grouped columns must be EXPLICITLY projected via `column(...)` in
// the aggregate spec — they are NOT auto-included (SQL rule: a
// selected non-aggregate column must be in GROUP BY).
.aggregate({
tenantId: column('tenantId'),
status: column('status'),
orders: count(),
})
.descriptor,
)
return rows.map((r) => ({
tenantId: (r as { tenantId: string }).tenantId,
status: (r as { status: string }).status,
orders: (r as { orders: number }).orders,
}))
},
})output MUST match each returned row's shape. build runs as the system (no per-request subject), so it reads across every tenant; tenantId is part of the grouping so reads stay per-tenant. Reach for an aggregate when the source query is expensive but the result is small + bounded.
Startup — a run-once boot hook
A *.startup.tsx default-exports a function (no define* wrapper). It runs once after migrations + seeds, when the rpc server is listening, and holds a resource for the process lifetime.
// startup/warm.startup.tsx
import type { StartupContext } from '@voltro/cli/startup'
export default async ({ store, log, onShutdown, id }: StartupContext) => {
log.info(`startup '${id}': warming order-fulfillment caches`)
// Example long-lived resource: a heartbeat interval. Replace with a real
// warm-up (preload a dashboard cache, open a consumer, …).
const timer = setInterval(() => {
void store // `store` is the already-migrated DataStore, ready to use.
}, 60_000)
// Teardown runs on SIGTERM / SIGINT, LIFO across all startups, each
// awaited with a hard 5s timeout. Always release what you acquire.
onShutdown(() => {
clearInterval(timer)
log.info(`startup '${id}': torn down`)
})
}A throw here does NOT block boot — it's logged; the rpc surface stays up. Distinct from *.seed.ts (runs once and RETURNS) and *.cron.tsx (periodic): a startup HOLDS a resource until shutdown. StartupContext is imported from the @voltro/cli/startup subpath.
Try it
Over the rpc surface (a voltro dev web client, POST /rpc, or the inspect invoke endpoint):
// 1. place — inserts the order and starts the fulfillment workflow via order.placed
{ "tag": "orders.place", "input": { "tenantId": "acme", "customerName": "Ada", "amountCents": 4200 } }
// 2. approve — wakes the parked awaitSignal('approval') gate
{ "tag": "orders.approve", "input": { "tenantId": "acme", "orderId": "order_…", "approved": true } }Watch the run in the dashboard's Workflows tab (the awaitSignal gate also has a "Send signal…" button) and the order status walk placed → reserved → shipped live.
When to use api-durable vs. the variants
| You need… | Pick |
|---|---|
| The smallest CRUD reference to extend | api-backend |
| Workflows / triggers / schedules / aggregates exercised end-to-end | api-durable |
| Transactional email wired (React-Email) | api-backend-mail |
| File storage wired (public + private objects) | api-backend-storage |
| MariaDB binlog CDC + storage (K8s shape) | api-backend-mariadb |
api-durable is the durable-execution showcase; the notes-based variants build CRUD + one plugin on top of api-backend.
Pairs well with
- Any web template — the durable api streams its order status over the same reactive subscriptions every web template consumes.
store: 'postgres'— switch the store for durable state across restarts + cluster workflow runners.
Anti-patterns
- Emitting the domain event before the write commits.
orders.placeemitsorder.placedafterctx.store.insertreturns —ctx.events.emitis post-commit safe so a rolled-back transaction never starts a workflow for an order that doesn't exist. Don't emit eagerly inside the same expression as the insert. - Doing external I/O inside a mutation. The place mutation only inserts + emits. HTTP calls, payments, and other side effects belong in a workflow
step()(journaled + replayed) or an action — a mutation runs in a transaction and can't roll back an HTTP side effect. - Tracking the workflow run handle to send a signal.
orders.approvere-derives the executionId from the payload via the deterministicidempotencyKeyinstead. Storing the handle from the place mutation is unnecessary and breaks across restarts. - Dropping the cross-tenant write guard. Tables with
tenant()get automatic SUBSCRIPTION scoping, but a mutation that writes raw rows still needsassertOwnTenant(input.tenantId, ctx.request.subject)— andTenantMismatchmust be imported from@voltro/plugin-multitenancy/guardin the descriptor, never the package root (the root leaks@voltro/databaseinto the browser bundle). - Putting server-only imports in a workflow descriptor.
*.workflow.tsxis value-imported into the client rpcGroup. Keepstep/ database / cluster imports in the.server.tsxexecutor; the descriptor imports only@voltro/workflow/define.