Concepts
Events and Runs
An event is a named signal, something happened in your system: user:signup, order:paid, email:bounced.
When you emit() an event, the engine creates a run for each registered handler. A run is the unit of work: one handler processing one event, tracked from creation to completion.
If three handlers are registered for user:signup, emitting that event creates three independent runs. Each run has its own lifecycle, retry state, and correlation tracking.
The dispatcher is a concurrency-limited loop that claims idle runs and executes them. You control how many handlers run in parallel with the concurrency option.
Run State Machine
Every run follows a strict state machine. The engine enforces valid transitions, a run can never jump from idle to completed or from completed back to running.
| State | What’s happening |
|---|---|
| idle | Queued, waiting for the dispatcher to pick it up |
| running | Handler is executing, lease is held |
| completed | Handler returned successfully |
| errored | Failed terminally, or explicitly cancelled. Dead-lettered runs are still stored as errored with a derived dead-letter status. |
The errored → idle transition is the repair path, you can requeue failed runs for another attempt after fixing the underlying issue.
Retry and Dead Letters
Register a handler with a retry policy to handle transient failures automatically:
engine.register('charge', 'order:paid', handler, {
retry: { maxRetries: 3, delay: 1000 }
})
When a handler throws, the run returns to idle and retries after the delay. Each retry increments an attempt counter. After exhausting all retries, the run transitions to errored, gets the derived dead-letter status, and the onDead lifecycle hook fires.
Errored runs don’t disappear, they’re queryable and can be retried via engine.retryRun(id) or requeued with a fresh retry budget via engine.requeueDead(id).
Durable Effects
The hardest problem with retries: your handler sent an email, then crashed before recording success. On retry, do you send it again?
ctx.effect(key, fn) solves the common case. Once an effect completes successfully and its result is persisted, later attempts return the stored result instead of re-executing the function.
engine.register('charge', 'order:paid', async (ctx) => {
// First run: calls Stripe. Retry: returns stored result.
const payload = ctx.payload as { amount: number }
const charge = await ctx.effect('stripe-charge', async () => {
return await stripe.charges.create({ amount: payload.amount })
})
return { success: true, payload: charge }
})
The process() API offers a cleaner syntax with named-parameter effects and ok()/fail() helpers:
engine.process({
name: 'charge',
on: 'order:paid',
retry: { maxRetries: 3, delay: 1000 },
run: async (ctx) => {
const payload = ctx.payload as { amount: number }
const charge = await ctx.effect({
key: 'stripe-charge',
run: () => stripe.charges.create({ amount: payload.amount }),
})
return ctx.ok({ chargeId: charge.id })
},
})
Effects are keyed per-run, so the same handler can have multiple effects with different keys. Each is independently persisted and replayed.
Crash Recovery
What happens when the process itself dies, not just the handler, but the entire Node process?
The engine uses lease-based heartbeating. When a worker claims a run, it acquires a time-limited lease. A background loop extends the lease periodically. If the process crashes, the heartbeat stops, the lease expires, and the next engine instance reclaims the orphaned run.
This requires SQLite storage, in-memory mode has no persistence to recover from. On the next startup, reclaimed runs execute fresh, and any effects that already completed return their stored results instead of re-executing.
Event Chaining
Handlers can trigger follow-up events, building pipelines where one event flows into the next:
engine.register('validate', 'order:new', async (ctx) => {
return { success: true, triggerEvent: 'order:validated', payload: ctx.payload }
})
engine.register('charge', 'order:validated', async (ctx) => {
return { success: true, triggerEvent: 'order:charged' }
})
engine.register('fulfill', 'order:charged', async (ctx) => {
return { success: true }
})
The engine tracks the full chain with correlation IDs, every run in a chain shares the same correlation ID, so you can trace a single user action through every handler it triggered. A configurable max chain depth prevents infinite loops.
Deferred Events (Human-in-the-Loop)
Sometimes a chain should pause and wait for an external signal before continuing. A handler can return deferred: true alongside triggerEvent to store the next event without emitting it:
engine.register('validate', 'order:new', async (ctx) => {
const payload = ctx.payload as { orderId: string }
return {
success: true,
triggerEvent: 'order:approved',
deferred: true,
payload: { orderId: payload.orderId },
}
})
engine.register('fulfill', 'order:approved', async (ctx) => {
return { success: true }
})
The run completes normally, but order:approved is not emitted. The chain is paused. Later, when approval arrives (from a webhook, a UI button, an API call, a Telegram message, whatever):
engine.resume(runId)
resume() reads the stored triggerEvent and payload from the completed run and emits the event, continuing the chain with the original correlation ID and context intact. You can also merge additional payload:
engine.resume(runId, { approvedBy: 'alice' })
Deferred runs are queryable like any other completed run. The raw state remains completed, while API and dashboard views expose a derived deferred status from result.deferred and result.triggerEvent.