Event triggers
defineEventTrigger — bind a domain event name to a workflow. ctx.events.publish(descriptor, key, payload) fans the event out to every matching trigger with filter, payload-mapping, and idempotency.
A workflow is usually kicked off directly (ctx.workflows.start(...)). An event trigger decouples that: a *.trigger.tsx file binds a declared event to a workflow name, and any handler that publishes that event fans it out to every trigger listening for it. The emitter never names the workflow — "something happened" is separated from "run this workflow", so you add reactions without touching the code that emits.
// events/orders.event.ts — the declaration both sides share
export const orderPaid = defineEvent({
name: 'order.paid',
key: Schema.Struct({ orderId: Schema.String }),
payload: Schema.Struct({ total: Schema.Number, tenantId: Schema.String }),
})
// triggers/orderPaid.trigger.tsx
import { defineEventTrigger } from '@voltro/runtime'
export default defineEventTrigger({
on: orderPaid, // the declared event to listen for
workflow: 'fulfilment.run', // the workflow to start
// Optional: skip when the predicate returns false.
filter: (e) => e.data.total > 0,
// Optional: map the event envelope → the workflow's payload (default: e.data).
payload: (e) => ({ orderId: e.data.orderId, tenantId: e.data.tenantId }),
// Optional: dedup key — two emits with the same key start the workflow once.
idempotencyKey: (e) => `fulfil:${e.data.orderId}`,
})Discovery walks every *.trigger.tsx; the default export must be a defineEventTrigger(...) descriptor (one per file). The workflow runtime is active whenever the app has any workflow OR any trigger.
Emitting an event
From any handler with a ctx, publish the declared event:
const execute = (input: { orderId: string }, ctx: AppContext) => Effect.gen(function* () {
// ... mark the order paid ...
yield* ctx.events.publish(orderPaid, { orderId: input.orderId }, {
total: order.total,
tenantId: ctx.request.subject.tenantId,
})
})publish reaches BOTH audiences from one call: the durable one (this page — the event log plus every matching trigger) and the ephemeral one (connected clients). Inside a mutation both fire on COMMIT and neither on rollback, so a client and a triggered workflow cannot disagree about whether the thing happened.
on: reads the event NAME off the descriptor, so renaming the event moves the trigger with it. The older string form (event: 'order.paid') was removed in 0.25.0 — with a string, a rename left the trigger matching nothing and the workflow simply never ran again, without an error.
The event envelope every filter / payload / idempotencyKey receives is:
{ id, name, data, occurredAt, source, subject?, traceId? }subject + traceId are inherited from the emitting request, so a triggered workflow continues the same trace and carries the same actor.
Matching + delivery
- Exact + wildcard. A trigger matches its exact
eventname; a trigger registered for'*'matches every event (and runs in addition to exact matches). - Idempotency. Before starting, the runtime checks for an existing delivery with the same
idempotencyKey(default<eventId>:<triggerId>) — a duplicate is recorded asstatus: 'skipped', reason: 'duplicate'and the workflow is not started twice. - Every emit is audited. The event itself lands in
_voltro_workflow_events; one row per trigger lands in_voltro_workflow_event_deliverieswithstatus(starting → started, orskipped/failed), the startedexecutionId, and any error message. Both tables are reactive — the dashboard's Workflows view surfaces deliveries live.
Retention — and why the delivery TTL is not just housekeeping
Both audit tables are append-only, so both are swept on a 30-day default by the
boot retention GC (postgres): VOLTRO_WORKFLOW_EVENTS_TTL_HOURS and
VOLTRO_WORKFLOW_EVENT_DELIVERIES_TTL_HOURS. The delivery log grows faster — one
row per trigger, so three triggers on one event write four rows per emit.
The delivery TTL is the deduplication window. The idempotency check above looks
for an existing delivery row, so once a row is swept its key is no longer
deduplicated. With the default key (<eventId>:<triggerId>, and eventId is fresh
per emit) a duplicate cannot occur and this costs nothing. It matters only when you
supply your own idempotencyKey: if your app can re-emit the same stable key
(fulfil:order-123) more than 30 days apart and must still be deduplicated, raise
VOLTRO_WORKFLOW_EVENT_DELIVERIES_TTL_HOURS past that horizon.
Unlike _voltro_outbox, the delivery log is not status-filtered — a stale
starting row has no requeue path and no reader, so keeping it would leave the
table unbounded for exactly the rows a crash produces.
When to use it
Reach for an event trigger when ONE thing happening should fan out to several independent reactions, or when you want the emitter to stay ignorant of the consumers:
order.paid→ start fulfilment AND a receipt-email workflow AND an analytics rollup — three triggers, onepublish.- A mutation publishes
user.signedUp; an onboarding workflow trigger starts the drip sequence. The signup mutation never imports the onboarding workflow.
If the handler already knows exactly which workflow to run and there's only one, call ctx.workflows.start(...) directly — the trigger indirection only pays off when you're decoupling publish from react, or fanning one event out to many.