ZenZip logozenzip

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#

emit.ts
// 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: triggers

emit 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#

subscribers.ts
// 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#

triggers.ts
// 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 trigger

Match predicates (waitForEvent)#

match.ts
// 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 waiting

match 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#

PatternMatchesDoesn't match
user.createduser.createduser.created.eu
user.*user.created · user.deleteduser.created.eu
user.**user.created · user.created.euorder.created
*.createduser.created · order.createduser.deleted

Patterns apply to app.on() subscribers and workflow on: triggers. waitForEvent uses exact names (+ match predicates).

Delivery semantics#

ConsumerGuarantee
Workflow `on:` triggerat-least-once, crash-safe (created atomically with the event)
waitForEvent waiterat-least-once; step result recorded in the emit transaction
app.on() subscriberbest-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.