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-durable

No 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 teardown

Every 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

  1. Place an orderorders.place inserts the row, then emits order.placed after the transaction commits — so a rollback never starts a workflow for an order that doesn't exist.
  2. The trigger firesorder.placed.trigger.tsx starts the durable orders.fulfill workflow with { orderId, tenantId }.
  3. The workflow runs durably → reserves stock (with saga-style compensation), waits a journaled delay, then parks on awaitSignal('approval').
  4. A human approvesorders.approve re-derives the run's deterministic executionId and injects the approval signal; 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 execute

TenantMismatch 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 buildExecute

Three 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 execute

Because 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.place emits order.placed after ctx.store.insert returns — ctx.events.emit is 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.approve re-derives the executionId from the payload via the deterministic idempotencyKey instead. 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 needs assertOwnTenant(input.tenantId, ctx.request.subject) — and TenantMismatch must be imported from @voltro/plugin-multitenancy/guard in the descriptor, never the package root (the root leaks @voltro/database into the browser bundle).
  • Putting server-only imports in a workflow descriptor. *.workflow.tsx is value-imported into the client rpcGroup. Keep step / database / cluster imports in the .server.tsx executor; the descriptor imports only @voltro/workflow/define.