Lucid
The Lucid StateStore driver — persist runs and checkpoints to Postgres, MySQL, or SQLite through @adonisjs/lucid. The migration ships with @adonis-agora/durable; select the store with stores.lucid().
The lucid driver persists runs and step checkpoints through @adonisjs/lucid — on Postgres, MySQL, or SQLite. Timestamps and wakeAt are stored as epoch-ms big integers, so the schema is dialect-portable. It ships inside @adonis-agora/durable; @adonisjs/lucid is an optional peer, imported lazily only when you select the lucid store.
Publish the migration
node ace configure @adonis-agora/durableconfigure registers the provider, publishes config/durable.ts, and publishes the migration that creates the six durable tables (alongside the optional db-transport migration — delete that one if you don't use the db transport).
Run the migration
node ace migration:runThe published migration delegates to the library rather than carrying a snapshot of the DDL:
import { BaseSchema } from '@adonisjs/lucid/schema'
import db from '@adonisjs/lucid/services/db'
import { createDurableTables, dropDurableTables } from '@adonis-agora/durable'
export default class extends BaseSchema {
static disableTransactions = true
async up() {
await createDurableTables(db, this.db.connectionName)
}
async down() {
await dropDurableTables(db, this.db.connectionName)
}
}Two details are load-bearing rather than stylistic:
static disableTransactions = trueis required.createDurableTablesgoes through theDatabasemanager, which checks out its own connection from the pool — the statements were never inside the migrator's transaction to begin with. On apool: { max: 1 }setup (Adonis's own SQLite guidance) that transaction would hold the single connection whilecreateDurableTableswaited for a free one, and the migration would hang until the acquire timeout. Nothing is lost by opting out: re-running an interruptedcreateDurableTablesis a no-op.dbfrom@adonisjs/lucid/services/db, notthis.db.createDurableTablesneedsdb.connection(name).schema, and thethis.dba migration receives is a single already-resolvedQueryClientContractwith no.connection().this.db.connectionNamestill supplies the name, sonode ace migration:run --connection=reportingprovisions the tables on that connection rather than the default.
The schema belongs to the library — LucidStateStore decides which columns it reads and writes — so a hand-copied snapshot is only ever right until the next release. Delegating makes that drift impossible instead of merely testable.
Select the store
store names a key of the stores map. stores.lucid() rides your app's default Lucid connection — the driver resolves the db service for you when it starts:
import { defineConfig, stores } from '@adonis-agora/durable'
export default defineConfig({
store: 'lucid',
stores: {
lucid: stores.lucid(),
},
})Pass { connection } to target a non-default Lucid connection:
stores.lucid({ connection: 'durable' })What the migration creates
The migration creates six tables (default names, from the DURABLE_TABLES constant):
| Table | Purpose |
|---|---|
durable_workflow_runs | one row per run: status, input/output/error, wake_at, lock columns, recovery_attempts, tags, search attributes |
durable_step_checkpoints | one row per step (run_id, seq): name, kind, status, input/output/error, attempts, events, timing |
durable_run_attributes | normalized side-table of searchable attributes (key + str_value/num_value) for fast filtering |
durable_signal_waiters | runs parked on a signal token |
durable_buffered_signals | signals delivered before the run waited (FIFO per token) |
durable_buffered_events | named events published before any run was waiting, keyed by name and indexed (name, published_at) for the oldest-first redelivery scan |
The runs table is indexed on status and on (status, wake_at) so the worker's listPendingRuns and listDueTimers scans stay fast; checkpoints are keyed (run_id, seq) with a (run_id, name) index for targeted reads; and the attributes table is indexed per (key, value) so search-attribute filters push down to SQL.
Who provisions the schema: autoSchema
By default the provider provisions the store's schema at boot by calling ensureSchema(), which for the Lucid driver is exactly the createDurableTables(db, connection) the migration runs. It is idempotent — every table is hasTable-guarded and every column hasColumn-guarded — so it is safe on a database that already has some or all of the tables. The library managing its own tables is the ecosystem convention, shared with @adonis-agora/agent, @adonis-agora/authz and @adonis-agora/telescope.
Turn it off to own the schema entirely through migrations:
export default defineConfig({
store: 'lucid',
stores: { lucid: stores.lucid() },
// No DDL at boot. The published migration is now the ONLY thing that
// creates or repairs these tables.
autoSchema: false,
})Reach for autoSchema: false when the app's database user may not run DDL at boot, or when every schema change must be an explicit, reviewed migration. The in-memory store has no schema, so the flag is a no-op for it.
autoSchema: false means nothing repairs the schema for you
With the boot-time provisioning off, a database that is behind the installed library version stays behind. Run node ace migration:run after every upgrade — the migration calls the same createDurableTables, so it brings the schema forward.
The repair warning
createDurableTables is additive-only. When it finds a table that exists but is missing a column the current library writes, it adds the column with an ALTER TABLE — every added column is nullable or defaulted, so existing rows read back unchanged. When at least one such repair is actually applied, it logs a single warning:
@adonis-agora/durable: repaired the durable schema in place — added
durable_step_checkpoints.last_heartbeat_at, durable_step_checkpoints.heartbeat_progress.
The database was behind the installed library version, so these columns were added at
runtime and nothing recorded it in your migration history. …Read it as: the database was behind, the library caught up silently, and your migration history does not know. Fix it by running node ace migration:run so the change is recorded the same way every other schema change is.
The warning only fires when a repair was genuinely applied. A fresh create says nothing, and so does a boot against an already-current schema — which is the point: a warning on every boot would be filtered as noise, and that noise is exactly what let a wrong migration-managed schema go undetected before.
Pass your own sink to route it into the app's log stream instead of console:
import logger from '@adonisjs/core/services/logger'
async up() {
await createDurableTables(db, this.db.connectionName, { logger })
}The logger only needs a warn(message: string) method, which both console (the default) and AdonisJS's Logger satisfy.
Read API for dashboards & tooling
Beyond the durability primitives, the Lucid store implements the read side the dashboard and CLI lean on — listRuns(query) with workflow/status/tag/attribute filters, ordering, and pagination; listCheckpoints(runId) for a run's timeline; and the targeted-read fast paths getLatestCheckpointByName / listCheckpointsByNamePrefix used by queries and child-run lookups.
Overview
Where durable state lives. A config-driven StateStore with a Lucid driver that runs on Postgres, MySQL, or SQLite, an in-memory store for tests, and a migration shipped with @adonis-agora/durable.
Dashboard
The embedded operations console — a React SPA plus a JSON API mounted into your AdonisJS routes. Filter runs, live-tail a run's timeline, fix-and-replay a bad input, deliver signals/updates/task completions, pause and trigger schedules, act on runs in bulk, and read the worker fleet's health — with the whole wire contract published as OpenAPI 3.1.