Events
An atomic event outbox: emits persist the event, wake matching waiters, and durably trigger workflows — all in one transaction. Plus ephemeral wildcard subscribers.
app.emit#
// Anywhere: a route handler, a queue consumer, a REPL.
const { woken, triggered } = app.emit("invoice.paid", {
invoice: "inv_42",
amount: 4200,
});
// woken → waitForEvent waiters released
// triggered → workflow runs created via on: triggersemit is synchronous and transactional: by the time it returns, the event row is persisted, every matching waitForEvent waiter has its step result recorded and its run re-enqueued, and every on:-triggered run exists with its execution job — one store transaction. There is no window where an event happened but its consequences didn't.
app.on — ephemeral subscribers#
// Ephemeral, this-process, fire-and-forget. Wildcards supported.
const off = app.on("user.*", ({ event, payload }) => {
analytics.track(event, payload);
});
app.on("billing.**", auditLog); // matches billing.invoice.paid.eu …
off(); // unsubscribe- In-process, fire-and-forget: a throwing subscriber is logged, never crashes the emitter, and gets no retries.
- Scope: events emitted via
app.emit()and machine transitions in this process. For anything that must not be missed, use a workflow trigger instead.
Durable workflow triggers#
// DURABLE: created atomically with the event (outbox) — a persisted
// event implies its triggered runs exist. Survives crashes.
app.workflow("onboard", { on: "user.created" }, async ({ step, input }) => {
// input: { event: "user.created", payload: {...}, emittedAt: 1717... }
await step.run("welcome", () => emails.push({ to: input.payload.email }));
});
// Multiple patterns:
app.workflow("audit", { on: ["user.**", "billing.**"] }, async ({ input }) => {
await audit.log(input.event, input.payload);
});Triggered runs are full workflow runs: journaled steps, retries, suspensions, the dashboard — everything in Workflows applies. State machine transitions ride the same rails:
// State machine transitions emit through the same bus:
const order = app.machine("order", {
initial: "created",
states: { created: { on: { PAY: "paid" } }, paid: {} },
});
app.workflow("on-paid", { on: "order.paid" }, async ({ input }) => {
// payload: { machine: "order", id, from: "created", event: "PAY", to: "paid" }
});
await order.send("ord_1", "PAY"); // transition + durable triggerMatch predicates (waitForEvent)#
// Wake only the run waiting for THIS invoice:
const wf = app.workflow("collect", async ({ step, input }) => {
const paid = await step.waitForEvent("paid", "invoice.paid", {
timeout: "24h",
match: { invoice: input.invoice }, // shallow equality on the payload
});
return paid ? "collected" : "reminder-time";
});
await wf.trigger({ invoice: "inv-a" });
await wf.trigger({ invoice: "inv-b" });
app.emit("invoice.paid", { invoice: "inv-b", amount: 42 });
// → { woken: 1 } — inv-a keeps waitingmatch is shallow equality on top-level payload keys — every key in the predicate must equal the same key in the emitted payload. Deeper predicates (JSON-path) are a later extension.
Wildcard patterns#
| Pattern | Matches | Doesn't match |
|---|---|---|
user.created | user.created | user.created.eu |
user.* | user.created · user.deleted | user.created.eu |
user.** | user.created · user.created.eu | order.created |
*.created | user.created · order.created | user.deleted |
Patterns apply to app.on() subscribers and workflow on: triggers. waitForEvent uses exact names (+ match predicates).
Delivery semantics#
| Consumer | Guarantee |
|---|---|
| Workflow `on:` trigger | at-least-once, crash-safe (created atomically with the event) |
| waitForEvent waiter | at-least-once; step result recorded in the emit transaction |
| app.on() subscriber | best-effort, this process, no retries |
Machine transitions are atomic too
A machine transition, its history entry, its event, and any runs the event triggers all commit in the same store transaction — there is no transition-without-event crash window.
Cross-process event fan-out (multiple machines) is available with the Postgres backend via LISTEN/NOTIFY.