Codegen
The build-time code durable generates and publishes. Assembler init hooks write lazy barrels of app/workflows and app/steps so the provider skips its runtime directory scan, and an OpenAPI 3.1 document of the dashboard API lets you generate a typed client for it.
Durable does two kinds of code generation. Neither is required to run a workflow:
- Discovery barrels. Two AdonisJS Assembler
inithooks write typed, lazy barrels ofapp/workflowsandapp/stepsinto.adonisjs/durable/. The provider imports them at boot instead of scanning those directories withreaddir. - A dashboard API contract. The dashboard serves an OpenAPI 3.1 document of its JSON API, which you can feed to any OpenAPI client generator.
Workflow and step barrels
@adonis-agora/durable/hooks/workflows and @adonis-agora/durable/hooks/steps are Assembler init hooks. They work the same way @adonisjs/core generates its controllers/events/listeners barrels. The dev server, the test runner and node ace build run them, and the file watcher re-runs the generator whenever a file in the scanned directory is added or removed, so the barrels always reflect the directories.
Setup
node ace configure @adonis-agora/durable registers both hooks for you:
export default defineConfig({
hooks: {
init: [
() => import('@adonis-agora/durable/hooks/workflows'),
() => import('@adonis-agora/durable/hooks/steps'),
],
},
})It also adds the subpath imports the generated barrels import through to your package.json:
{
"imports": {
"#workflows/*": "./app/workflows/*.js",
"#steps/*": "./app/steps/*.js"
}
}configure never overwrites an imports entry you already define. If you wire the hooks by hand, add these entries too. Without them, the build succeeds but the app fails at boot with ERR_PACKAGE_IMPORT_NOT_DEFINED.
What gets generated
| Hook | Scans | Writes | Export |
|---|---|---|---|
@adonis-agora/durable/hooks/workflows | app/workflows | .adonisjs/durable/workflows.ts | workflows |
@adonis-agora/durable/hooks/steps | app/steps | .adonisjs/durable/steps.ts | steps |
Each barrel is a map of key to lazy import. Keys drop the workflow / step file suffix, the way controller barrels drop controller:
export const workflows = {
Charge: () => import('#workflows/charge_workflow'),
// ...
}How the provider uses them
At boot the provider imports .adonisjs/durable/workflows.js. It awaits each thunk and registers every BaseWorkflow subclass it finds, through the container so constructor injection works. Each class is registered once, even when it's re-exported from several modules. It does the same with .adonisjs/durable/steps.js, serving every @Step / defineStep export by name on the app's transport, and resolves each @Step class through the container as well, on every step execution.
When a barrel file doesn't exist (because the hook isn't registered), the provider falls back to scanning the configured directories at runtime (workflowsPath, default app/workflows; stepsPath, default app/steps). Registration is identical either way. Only a genuine "module not found" triggers the fallback: a barrel that exists but fails to import throws at boot instead of quietly degrading. Setting workflowsPath: false or stepsPath: false in config/durable.ts turns discovery off entirely, barrel included.
The barrels serve the app process's provider. A dedicated worker process started with node ace durable:worker registers its step handlers by scanning every stepsPath directory at runtime and doesn't read the steps barrel.
Options
The default export of each hook module uses the defaults. For a different directory or alias, export the factory (workflowsHook / stepsHook, exported from the same subpaths) with options from your own hook module and register that module instead:
import { workflowsHook } from '@adonis-agora/durable/hooks/workflows'
export default workflowsHook({ source: 'app/flows', importAlias: '#flows' })| Option | workflowsHook default | stepsHook default | Description |
|---|---|---|---|
source | 'app/workflows' | 'app/steps' | Directory scanned, relative to the app root, or an array of directories (see Several directories). |
importAlias | '#workflows' | '#steps' | Import alias each barrel entry uses. Needs a matching package.json imports entry. Single source only: passing it with several directories throws. |
output | '.adonisjs/durable/workflows.ts' | '.adonisjs/durable/steps.ts' | Where the barrel is written. |
Keep the default output path
The provider always imports the barrel from the fixed .adonisjs/durable/workflows.js / .adonisjs/durable/steps.js path. If you change output, the provider won't find the barrel and silently falls back to the runtime directory scan.
Several directories
A modular app with one folder per bounded context passes an array to source. Each hook still writes one barrel at the same output path, so the provider finds it as before:
import { workflowsHook } from '@adonis-agora/durable/hooks/workflows'
export default workflowsHook({ source: ['app/orders/flows', 'app/billing/flows'] })import { stepsHook } from '@adonis-agora/durable/hooks/steps'
export default stepsHook({ source: ['app/orders/steps', 'app/billing/steps', 'app/engine'] })Register those modules in adonisrc.ts in place of the package defaults. With several directories the barrel is an array with one entry per root. Each module is keyed by its path from the app root and imported through a path relative to the barrel, so you don't need a package.json imports entry per directory:
export const workflows = [
{
root: 'app/orders/flows',
modules: {
'app/orders/flows/place_workflow.ts': () => import('../../app/orders/flows/place_workflow.js'),
},
},
{
root: 'app/billing/flows',
modules: {
'app/billing/flows/charge_workflow.ts': () => import('../../app/billing/flows/charge_workflow.js'),
},
},
]The file watcher regenerates the barrel when a file is added to or removed from any of the directories. A one-element array produces exactly the single-directory barrel, and an empty array throws: to turn a barrel off, remove its hook.
The duplicate rule from the runtime scan applies here too. If two roots define the same workflow name and version, or the same step name, boot fails with a DuplicateDiscoveryError that names both module paths. Keep workflowsPath / stepsPath in config/durable.ts set to the same directories, so the runtime fallback (and durable:worker) agrees with the barrel.
A typed client for the dashboard API
Durable doesn't ship a generated or prebuilt client for the dashboard's REST API. What it ships is the contract: the dashboard serves a hand-written OpenAPI 3.1 document of its JSON API, and you can generate a client from it with any OpenAPI tool. A spec in the durable repo asserts the document matches the registered routes, so a handler change can't silently drift from it.
Getting the document
The document is served at GET <path>/api/openapi.json, which is /durable/api/openapi.json with the default path:
curl -H "Authorization: Bearer $DURABLE_DASHBOARD_TOKEN" \
http://localhost:3333/durable/api/openapi.json > durable-openapi.jsonThe route sits behind the same authorize gate as the rest of the API, so send the dashboard token (or a session) like any other API call. Its servers entry is the mounted API base (/durable/api, or <path>/api), so paths in the document are relative to it (/runs, /runs/{id}/retry, …).
The document isn't exported as a library function. The served route is the way to get it, and committing the downloaded durable-openapi.json keeps client generation reproducible offline and in CI. Diffing a freshly fetched copy against the committed one after an upgrade shows exactly what changed in the wire contract.
Generating types with openapi-typescript
npm i -D openapi-typescript
npm i openapi-fetch
npx openapi-typescript ./durable-openapi.json -o ./types/durable-api.d.tsThe document defines no operationIds, so a path-keyed client such as openapi-fetch fits better than a generator that names methods after operations:
import createClient from 'openapi-fetch'
import type { paths } from './durable-api'
export const durable = createClient<paths>({ baseUrl: '/durable/api' })// List failed runs (typed RunSummary[])
const { data } = await durable.GET('/runs', { params: { query: { page: 1, size: 50 } } })
// Run detail: run + step timeline + children
const { data: detail } = await durable.GET('/runs/{id}', { params: { path: { id: runId } } })
// Retry it
if (detail?.run?.status === 'failed') {
await durable.POST('/runs/{id}/retry', { params: { path: { id: runId } } })
}What the generated types cover:
- Fully shaped: the components
RunStatus,RunSummary,Checkpoint,RunResultandError, theGET /runspage (runs,meta,statuses), theGET /runs/{id}detail (run,timeline,children,fans,childCount) and itsGET /runs/{id}/fans/{group}page, path and query parameters, and request bodies. - Conditional run detail:
GET /runs/{id}declares anIf-None-Matchheader parameter and a bodiless304. The200carries anETagwithCache-Control: private, no-cache, so a poller that sends the lastETagback pays nothing for an unchanged run; withopenapi-fetch, checkresponse.status === 304before readingdata. - Non-JSON routes:
GET /metricsis Prometheus text (text/plain, format 0.0.4), not JSON — fetch it as text or point a scraper at it (see Metrics). - Other 0.51 routes:
GET /runs/{id}/events/{key}returns{ value }(the latest value the run published withctx.setEvent,valueabsent when never published), andPOST /webhooks/{token}delivers actx.webhook()callback (404for an unknown or no-longer-waiting token,413for an oversized body). - Loosely typed: many action and fleet responses (retry, cancel, bulk, schedules, health, workers, topology, compat, …) are declared as a plain
object, so you get the route and its parameters typed but should narrow the response yourself. The dashboard page documents what each returns. - Filters beyond
page/sizeonGET /runs(thefilter[...]envelope and flatstatus/workflow/tag/ … params) are described in prose rather than as declared parameters. Pass them as extra query keys.
GET /runs/{id}/stream is a Server-Sent Events endpoint, so read it with EventSource (see Live-tailing one run) rather than through the generated client.
It's still a control surface
A generated client talks to the same routes the console does, including retry, cancel, bulk and the human-in-the-loop verbs. A browser client in your own frontend needs the dashboard to accept its requests: configure dashboardAuth or a custom authorize that recognises your app's session rather than shipping the bearer token to the browser. Mutating requests are also subject to the dashboard's cross-site rejection and session-role checks.
For server-side code in the same app, you don't need HTTP at all. The handlers behind the routes (listRuns, getRun, getRunEvent, retryRun, cancelRun, deliverWebhook, metricsText, …) are exported from @adonis-agora/durable/dashboard, and runGateway from @adonis-agora/durable/services/main is the role-agnostic read/control surface. See Calling the API directly.
Linting for non-determinism
Catch Date.now(), Math.random(), process.env, swallowed control-flow signals and direct I/O inside a workflow body at author time with @adonis-agora/durable-eslint-plugin — AST-scoped ESLint rules that know both the engine.register() function form and a BaseWorkflow class.
Testing
Unit-test workflows with an in-memory engine harness, a clock you control for durable sleep, crash/flaky-step injection, assertions that read the recorded state, and a replay-CI loop (durable:export → parseRunHistory → assertReplayable) that fails the build on a determinism break — no Postgres, no Redis, no real time.