Aviary
State stores

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:

StoreDatabaseSchema
InMemoryStateStore (in core)For tests and local dev. Not durable.
MikroORMPostgres · MySQL · SQLiteauto-schema or ensure*The reference adapter.
TypeORMPostgres · MySQL/MariaDB · SQLiteauto-schema or ensure*Register ENTITIES on your DataSource.
Prismaany Prisma datasourcePrisma MigrateAdd the models, prisma generate, pass your PrismaClient.
DrizzleSQLite / libSQL (Turso, edge)drizzle-kitBuild 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.

On this page