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.
Drizzle (Postgres)PostgreSQLdrizzle-kit or auto-schema@dudousxd/nestjs-durable-store-drizzle/pg — any drizzle Postgres driver.

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, TypeORM and Drizzle (Postgres) 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 adapters exports the exact function to call from your own migration — ensureMikroOrmDurableSchema(orm), ensureTypeOrmDurableSchema(dataSource) and ensureDrizzlePgDurableSchema(db) (whose SQL is also exported as DURABLE_PG_DDL). 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 (SQLite) 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). The Drizzle Postgres store supports both: re-export its pgTables into your drizzle-kit schema, or let auto-schema create the identical tables.

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 set of terminal statuses (disjoint per scope — a policy can be confined to a namespace, tags, workflows or search attributes); 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 custom store without it no-ops with a warning instead of failing boot. Every bundled adapter implements it, along with the bulk deleteRuns behind engine.purgeRuns (see Retention).

withScope is MikroORM + Drizzle (Postgres) 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 and Drizzle Postgres adapters implement it. Against TypeORM, Prisma or Drizzle (SQLite), 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