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.

flowchart LR E["emit('user:signup')"] --> R1["Run: send-welcome"] E --> R2["Run: create-profile"] E --> R3["Run: notify-admin"] R1 --> D[Dispatcher] R2 --> D R3 --> D D --> H1["✓ completed"] D --> H2["✓ completed"] D --> H3["↻ retry"]

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.

stateDiagram-v2 [*] --> idle: emit() idle --> running: dispatcher claims running --> completed: handler succeeds running --> errored: retries exhausted running --> idle: retry idle --> errored: cancel errored --> idle: repair / requeue
StateWhat’s happening
idleQueued, waiting for the dispatcher to pick it up
runningHandler is executing, lease is held
completedHandler returned successfully
erroredFailed 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.

sequenceDiagram participant D as Dispatcher participant H as Handler participant S as RunStore D->>H: Attempt 1 H-->>D: throws Error D->>S: idle (retry in 1s) Note over D: waits 1 second D->>H: Attempt 2 H-->>D: throws Error D->>S: idle (retry in 1s) Note over D: waits 1 second D->>H: Attempt 3 H-->>D: throws Error D->>S: errored (retries exhausted) Note over S: onDead 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.

sequenceDiagram participant H as Handler participant E as EffectStore participant S as Stripe API Note over H: First attempt H->>E: ctx.effect('charge', fn) E->>E: No stored result E->>S: Execute fn() S-->>E: { id: 'ch_123' } E->>E: Persist result E-->>H: { id: 'ch_123' } Note over H: Handler crashes Note over H: Retry H->>E: ctx.effect('charge', fn) E->>E: Found stored result E-->>H: { id: 'ch_123' } Note over S: NOT called again
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.

sequenceDiagram participant W1 as Worker 1 participant DB as SQLite participant W2 as Worker 2 W1->>DB: Claim run (30s lease) W1->>DB: Heartbeat (extends lease) W1->>DB: Heartbeat Note over W1: Process crashes Note over DB: Lease expires (30s) W2->>DB: Startup scan W2->>DB: Reclaim expired run W2->>W2: Retry handler Note over W2: Completed effects return stored results

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 }
})
flowchart TD A["order:new"] --> B["validate"] B -->|triggerEvent| C["order:validated"] C --> D["charge"] C --> E["reserve-inventory"] D -->|triggerEvent| F["order:charged"] E -->|triggerEvent| G["inventory:reserved"] F --> H["send-receipt"] G --> I["notify-warehouse"]

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' })
sequenceDiagram participant H as Handler participant E as Engine participant X as External System H->>E: { triggerEvent: 'approved', deferred: true } E->>E: Store event, do NOT emit Note over E: Chain paused X->>E: engine.resume(runId) E->>E: Emit 'approved' with stored payload E->>H: fulfill handler runs Note over E: Chain continues

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.