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 event name; 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 as status: '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_deliveries with status (starting → started, or skipped / failed), the started executionId, 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, one publish.
  • 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.