Overview
Where durable state lives. A StateStore interface with MikroORM, TypeORM, Prisma and Drizzle adapters that run on Postgres, MySQL, SQLite or libSQL, an in-memory store for tests, and auto-schema on boot (opt-out, with a migration helper).
A state store persists workflow runs and step checkpoints — the source of truth for both
durability and the dashboard. It's a pluggable StateStore
interface, independent of the transport.
Writing a custom store? The interface includes renewRunLock(runId, owner, leaseUntilMs) (for
lease renewal while a run executes)
and listPendingRuns(limit) (so workers can poll for pending
runs to dispatch). All bundled adapters implement both.
It also declares runFacets(query) — (status, origin) counts, which is what lets the
console page its run list while its chips
keep reporting the whole set. That one is optional: a store that omits it still works, with the
caller falling back to counting a full listing. Every bundled adapter pushes it down to a
GROUP BY, and RunQuery.origin accepts null there and in listRuns to select the runs carrying
no origin at all.
runValueFacets(axis, query, opts?) is optional in the same way: the distinct VALUES of one
filter axis with counts, which is what lets a console offer a picker instead of a text box. A store
that omits it leaves those controls as free text. Every bundled adapter answers the run-table axes
with a GROUP BY; tag and the search-attribute axes are counted over a bounded scan, since their
values live outside the row being counted.
Adapters
Each adapter has its own page with the full setup — register/copy the schema, wire the store, and own migrations:
| Store | Database | Schema | |
|---|---|---|---|
InMemoryStateStore (in core) | — | — | For tests and local dev. Not durable. |
| MikroORM | Postgres · MySQL · SQLite | auto-schema or ensure* | The reference adapter. |
| TypeORM | Postgres · MySQL/MariaDB · SQLite | auto-schema or ensure* | Register ENTITIES on your DataSource. |
| Prisma | any Prisma datasource | Prisma Migrate | Add the models, prisma generate, pass your PrismaClient. |
| Drizzle | SQLite / libSQL (Turso, edge) | drizzle-kit | Build your db with the package's schema. |
The MikroORM and TypeORM adapters work on any SQL database their ORM supports — Postgres, MySQL or SQLite (timestamps use native datetime columns, so epoch values never overflow). The quickest wire is one factory:
import { MikroOrmStateStore } from '@dudousxd/nestjs-durable-store-mikro-orm';
DurableModule.forRootAsync({
inject: [MikroORM],
useFactory: (orm) => ({ store: new MikroOrmStateStore(orm), transport }),
});Auto-schema
For the MikroORM and TypeORM adapters, the module creates the durable tables on boot
(durable_workflow_runs, durable_step_checkpoints, durable_run_attributes,
durable_signal_waiters, durable_buffered_signals) — zero setup in dev. Turn it off in production
and own the schema via a migration:
DurableModule.forRoot({ store, autoSchema: false });Each of those two adapters exports the exact function to call from your own migration —
ensureMikroOrmDurableSchema(orm) and ensureTypeOrmDurableSchema(dataSource). See the
MikroORM and
TypeORM pages for the full
migration snippets. It only ever adds the durable tables — it never touches yours.
The Prisma and Drizzle adapters have no auto-schema: their ORMs already own your schema and
migration history, so you create the durable tables with prisma migrate / drizzle-kit (the
autoSchema option is a no-op for them).
Column naming
All four adapters agree on physical column names for the durable_* tables so a run written by one
is readable by another. The MikroORM and TypeORM adapters expose this as an explicit choice —
durableEntities({ naming }) — rather than leaving it to the host ORM's naming strategy, which is
what silently diverged the adapters in the past:
import { durableEntities } from '@dudousxd/nestjs-durable-store-mikro-orm'; // or -typeorm
entities: durableEntities({ naming: 'snake_case' }); // default; or 'preserve', or a (property) => string fn'snake_case' is the default and canonical (matched by the Prisma and Drizzle schemas too).
'preserve' keeps the camelCase property name verbatim — only for reading a table an old,
unpinned TypeORM/Prisma adapter produced. See the MikroORM
and TypeORM pages for the full signature.
Exactly-once DB writes with ctx.transaction
A plain ctx.step is at-least-once: if the process dies after the step's business write commits but
before its checkpoint does, the step re-runs on replay. ctx.transaction(name, fn) closes that gap by
writing the business row and the checkpoint in the same store transaction, so they commit or fail
together:
await ctx.transaction('create-order', async (tx) => {
await this.orders.insert(tx, { id: orderId, total }); // your write, on the store-native tx handle
// checkpoint commits atomically with the write above — never done-but-unrecorded
});fn receives the store-native transaction handle (a TypeORM/MikroORM EntityManager, a Prisma tx
client, or a Drizzle tx) — run your own writes on it, not on your app's regular connection. Needs a
SQL store that implements transaction() (all four bundled adapters do); throws on a store that
doesn't.
Encrypting payloads
CodecStateStore wraps ANY store and runs run/step payloads (input/output) through a
PayloadCodec — encoded on write, decoded on read — so they're never persisted in the clear. Metadata
the dashboard and queries need (id, status, workflow, tags, timestamps, the structured error) is
left untouched:
import { CodecStateStore } from '@dudousxd/nestjs-durable-core';
const store = new CodecStateStore(new TypeOrmStateStore(dataSource), {
encode: (value) => aesEncrypt(value),
decode: (value) => aesDecrypt(value),
});
DurableModule.forRoot({ store, transport });Both encode/decode are synchronous — wrap an async KMS call yourself if you need one. It's
adapter-agnostic, so it composes with any of the four stores.
Retention & pruning
Terminal runs (completed, failed, cancelled, dead) otherwise accumulate forever. The
retention module option sweeps them on an interval, hard-deleting whatever falls outside your
policies:
DurableModule.forRoot({
store,
transport,
retention: {
sweepInterval: '1m',
batchSize: 1_000,
policies: [
{ statuses: ['completed', 'cancelled'], maxAge: '14d', maxCount: 200 },
{ statuses: ['failed'], maxAge: '90d' }, // keep failures longer for debugging
],
},
});Each RetentionPolicy targets a disjoint set of terminal statuses; a run is kept only while it
satisfies every bound you set on its status (maxAge, maxCount, or both — the most-restrictive
wins) and is pruned the moment it violates one. sweepInterval: 0 runs the sweep once on boot only.
Pruning needs the store to implement pruneTerminalRuns — optional on StateStore, so a store
without it no-ops with a warning instead of failing boot. Of the four bundled adapters, only
MikroORM implements it today; TypeORM, Prisma and Drizzle no-op with the warning until they do.
withScope is MikroORM-only. Multi-tenant setups can turn on scopeReads: true (with
namespace set) to confine the operator's own reads to one tenant. The module applies this by
asking the store for a scoped view via an optional withScope(scope) capability — today only the
MikroORM adapter implements it. Against TypeORM, Prisma or Drizzle, scopeReads silently has no
effect: the store keeps returning every namespace's runs. If you need tenant-scoped reads on one of
those adapters, scope the query yourself rather than relying on scopeReads.
SQL (database)
A broker-less, DBOS-style transport — dispatched steps are rows in the database you already run. Workers claim tasks with SELECT … FOR UPDATE SKIP LOCKED. The table + claim contract is documented so Node and Python workers share it.
MikroORM
The reference StateStore adapter. Register the durable entities with your MikroORM config, pass the ORM to MikroOrmStateStore, and run on Postgres, MySQL or SQLite — with auto-schema on boot or an ensure* helper for your own migration.