Agora

Durable

Durable workflows for AdonisJS — write a workflow as plain code; every step is checkpointed, so it survives crashes and deploys. Steps can run across processes, with a built-in control plane.

@adonis-agora/durable brings durable execution to AdonisJS. You write a workflow as ordinary async code — call a step, use its result, call the next — and the engine records every step's output. If the process crashes or you deploy mid-run, the workflow resumes from the last checkpoint instead of starting over. Steps can run in-process or on a remote worker, but it stays one workflow, with one source of truth, and one end-to-end timeline.

The one rule

The engine recovers a run by replaying the workflow function from the top. Completed steps return their saved result instead of executing again, so your workflow body must be deterministic — no Date.now(), Math.random(), or direct I/O outside a step. See Durability & replay.

The problem it solves

Multi-step processes are usually scattered: a queued job here, a cron there, a manual retry script somewhere else, and no single place to read or watch the whole thing. When a process dies halfway through, you are left reconstructing which steps already ran. @adonis-agora/durable collapses that into three guarantees:

  • The flow becomes code, in one place. Read the workflow function and you understand the whole sequence — even when steps execute in different processes.
  • Durability by replay. A crash or deploy never re-runs completed work. Each step is checkpointed; on recovery, finished steps replay their saved result and only unfinished work executes.
  • End-to-end visibility. Because one engine owns the state, it knows about every step — including the remote ones — so a full-flow trace, dashboard, and Telescope view come almost for free.

Quickstart

The minimal loop — install, configure, define a workflow, start a run — with zero infrastructure: the in-memory store and an in-process transport. Swap those for a Lucid store and a queue transport when you go to production. For the full walkthrough, see Getting Started.

Install and configure the umbrella package:

node ace add @adonis-agora/durable

This registers the provider in adonisrc.ts and publishes config/durable.ts.

Define a workflow as a class under app/workflows/ — it is auto-registered at boot. Every ctx.step is checkpointed; ctx.waitForSignal suspends the run until a signal arrives:

app/workflows/checkout_workflow.ts
import { BaseWorkflow } from '@adonis-agora/durable'
import type { WorkflowCtx } from '@adonis-agora/durable'

export default class CheckoutWorkflow extends BaseWorkflow {
  static workflow = { name: 'checkout', version: '1' }

  async run(ctx: WorkflowCtx, order: { id: number; total: number }) {
    await ctx.localStep('reserveStock', async () => ({ reserved: true }))
    const approval = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`)
    if (!approval.approved) return { status: 'rejected' }
    await ctx.localStep('ship', async () => ({ shipped: true }))
    return { status: 'shipped' }
  }
}

Start a run from a controller. dispatch enqueues the run and returns { runId } immediately — the HTTP handler never blocks on workflow logic:

app/controllers/checkout_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import CheckoutWorkflow from '#workflows/checkout_workflow'
import Order from '#models/order'
import { createCheckoutValidator } from '#validators/checkout'

export default class CheckoutController {
  async store({ request, response }: HttpContext) {
    const { total } = await request.validateUsing(createCheckoutValidator)
    const order = await Order.create({ total })
    const { runId } = await CheckoutWorkflow.dispatch(
      { id: order.id, total: order.total },
      { runId: `checkout:${order.id}` }
    )
    return response.accepted({ runId })
  }
}

Resume it later, from your approval webhook — this completes and ships the run:

// engine.signal wakes the exact run suspended on this token
await engine.signal(`approve:${order.id}`, { approved: true })

Need the outcome inline instead? await engine.waitForRun(runId) resolves once the run settles.

Steps across processes

A step does not have to run in the same process. ctx.step(refOrName, input) is always dispatched, routed to a handler by name over a pluggable transport to wherever it lives. Author the handler as a @Step class under app/steps, or with defineStep for a typed ref:

app/steps/charge.ts
import { defineStep } from '@adonis-agora/durable'
import { z } from 'zod'

export const chargeCard = defineStep(
  'payments:charge-card',
  async (input: { orderId: number; amountCents: number }) => ({ chargeId: await charge(input) }),
  {
    input: z.object({ orderId: z.number().int(), amountCents: z.number().int() }),
    output: z.object({ chargeId: z.string() }),
    retries: 3,
  },
)

// in the workflow:
const charge = await ctx.step(chargeCard, { orderId: order.id, amountCents: order.total })

Steps under app/steps are served by name automatically; the escape hatch is transport.handle(name, fn). With the in-process transport a step runs in the same process; swap the transport for the queue transport to move it to a separate worker — without changing the workflow code.

What you get

  • Crash-proof by replay. Each step is checkpointed and runs exactly once, logically; only unfinished work executes after a restart.
  • Durable sleep and signals. Pause for minutes or months with ctx.sleep (no compute while waiting), or wait on a human approval or webhook with ctx.waitForSignal. Both survive restarts.
  • Bring Lucid, any SQL database. State lives in Postgres, MySQL, or SQLite through a StateStore interface, with a Lucid adapter and a published migration.
  • See the whole flow. A built-in control plane renders each run; OpenTelemetry and a Telescope view give you two more views of the same event log.

Where to go next

On this page