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 cross-process transport (queue, db or BullMQ) when you go to production. For the full walkthrough, see Getting Started.
Install and configure the umbrella package:
node ace add @adonis-agora/durableThis registers the durable and dashboard providers, the Ace commands and the codegen hooks in adonisrc.ts, adds the #workflows/* / #steps/* subpath imports, and publishes config/durable.ts, config/durable_dashboard.ts and the Lucid migrations.
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:
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:
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:
import engine from '@adonis-agora/durable/services/main'
// 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. The optional input/output schemas are Standard Schema, validated at the serve boundary:
import { defineStep } from '@adonis-agora/durable'
import { z } from 'zod' // any Standard Schema library works: zod, valibot, arktype, …
export const chargeCard = defineStep(
'payments:charge-card',
async (input) => ({ chargeId: await charge(input) }), // input typed from the schema
{
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 — or implement it in a Python worker — without changing the workflow code.
The split goes both ways: a separate worker can implement a step the AdonisJS workflow calls, or author the whole workflow itself in Python and call back into Adonis. Either way, the engine stays the single owner of durable state.
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 withctx.waitForSignal. Both survive restarts. - Bring Lucid, any SQL database. State lives in Postgres, MySQL, or SQLite through a
StateStoreinterface, with a Lucid adapter that provisions its own tables at boot (autoSchema, on by default) — or manage them yourself from a migration withcreateDurableTables. - See the whole flow. A built-in dashboard renders each run; OpenTelemetry, metrics, diagnostics and a Telescope view read the same event log.
- Cross-language cluster. Run a store-less tenant/control-plane topology, and interoperate with NestJS (nestjs-durable) and Python workers on one Redis wire.
Where to go next
Getting Started
Install, configure, register a workflow, and start your first run — step by step.
Patterns
A cookbook: human approval, webhook confirmation, batch fan-out, cron digests, booking sagas, rate-limited APIs, long-lived pollers.
Comparison
How this library compares to plain @adonisjs/queue jobs, Temporal, Inngest, and BullMQ — honestly.
Concepts
Durability, deterministic replay, and the mental model behind the engine.
Authoring
Child workflows, queries and updates, webhooks, entities, versioning, scheduling, and event-triggered workflows.
Reliability
Retries, sagas, dead-letter, onError routing, singleton, concurrency quotas, retention, and flow control.
Transports
The in-process transports, @adonisjs/queue, the broker-less DB transport, BullMQ, and the Redis control plane.
State stores
Persist runs to Postgres, MySQL, or SQLite with Lucid.
Observability
The dashboard, events and interceptors, OpenTelemetry traces, metrics, diagnostics, and the Telescope view.
Cluster
Store-less tenant pods, a control plane, the handshake, and cross-runtime interop.
Python
Implement steps and author whole workflows from a Python worker, over the same Redis wire.
Tooling
The determinism linter and build-time codegen (workflow/step barrels, a typed dashboard client).
Testing
createTestEngine, a controllable clock, failure injection, assertions, and replay CI.
CLI
durable:work, durable:worker, durable:runs, durable:retry, durable:export and make:workflow.
Running in production
The operational checklist: store and transport, topology, timers and recovery, draining on deploy, retention.
Troubleshooting
Symptom-first answers: NonDeterminismError, stuck runs, timers that never fire, steps that run twice.
API reference
Every WorkflowCtx member, BaseWorkflow, the engine surface, DurableConfig options, errors, and statuses — from source.