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 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/durable

This 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:

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:

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:

app/steps/charge.ts
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 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 that provisions its own tables at boot (autoSchema, on by default) — or manage them yourself from a migration with createDurableTables.
  • 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.

On this page

Partner

Your product as the official integration for Durable, right here.

Become a partner →