Aviary
State stores

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.

@dudousxd/nestjs-durable-store-mikro-orm is the reference adapter — the one every other store is checked against. It persists runs and step checkpoints through MikroORM, on any driver MikroORM supports (Postgres, MySQL, SQLite), using native datetime columns so wakeAt and timestamps never overflow.

pnpm add @dudousxd/nestjs-durable-store-mikro-orm

It declares @mikro-orm/core as a peer — it rides the MikroORM instance you already configure.

1. Register the durable entities

The adapter reads and writes through MikroORM repositories, so the durable entities must be part of your ORM's entity set. The package exports them as ENTITIES:

mikro-orm.config.ts
import { ENTITIES as DURABLE_ENTITIES } from '@dudousxd/nestjs-durable-store-mikro-orm';

export default defineConfig({
  // …your driver, dbName, etc.
  entities: [...DURABLE_ENTITIES, /* your own entities */],
});

ENTITIES is [WorkflowRunEntity, StepCheckpointEntity, RunAttributeEntity, SignalWaiterEntity, BufferedSignalEntity, BufferedEventEntity] — the six tables the engine owns (durable_workflow_runs, durable_step_checkpoints, durable_run_attributes, durable_signal_waiters, durable_buffered_signals, durable_buffered_events). They never reference your entities.

Custom repositories

Each entity comes with a repository class, so app code that reads a durable table injects a typed handle instead of passing the entity class to every call:

import {
  WorkflowRunEntity,
  WorkflowRunRepository,
} from '@dudousxd/nestjs-durable-store-mikro-orm';
import { InjectRepository } from '@mikro-orm/nestjs';

@Injectable()
export class RunsService {
  constructor(
    @InjectRepository(WorkflowRunEntity)
    private readonly runs: WorkflowRunRepository,
  ) {}

  failed() {
    return this.runs.findAll({ where: { status: 'failed' } });
  }
}

em.getRepository(WorkflowRunEntity) returns the same thing, correctly typed — the entity declares its repository through MikroORM's [EntityRepositoryType] symbol. The full set is WorkflowRunRepository, StepCheckpointRepository, RunAttributeRepository, SignalWaiterRepository, BufferedSignalRepository and BufferedEventRepository.

They are wired onto the schemas that durableEntities() builds, so they come along with any naming — including a second call with a different mapping. Their bodies are empty: the engine's own reads and writes go through MikroOrmStateStore, and these exist purely as an injectable handle for your code. Subclass one in your app if you want to hang query helpers off it.

Repository reads are ordinary EntityManager reads, so the namespace global filter below applies to them. On an EntityManager that never sets the filter param this is a no-op (you see every namespace, the operator view); on one scoped to a tenant, the repository is scoped with it.

Column naming

ENTITIES is pinned to the canonical snake_case columns. If you need a different mapping — e.g. 'preserve' to read a table an older, unpinned setup wrote in camelCase, or a custom function — build the schemas yourself with durableEntities({ naming }) instead of importing ENTITIES:

import { durableEntities } from '@dudousxd/nestjs-durable-store-mikro-orm';

export default defineConfig({
  entities: [...durableEntities({ naming: 'snake_case' }), /* your own entities */],
});

naming accepts 'snake_case' (default), 'preserve', or (property: string) => string. The mapping is pinned explicitly on the entity schema rather than left to MikroORM's own naming strategy, since depending on the host strategy is what silently diverges the adapters — a run written by MikroORM has to stay readable by TypeORM, Prisma or Drizzle against the same table.

2. Wire the store into the module

MikroOrmStateStore takes the MikroORM instance. Inject it and build the store in the factory:

app.module.ts
import { MikroORM } from '@mikro-orm/core';
import { MikroOrmStateStore } from '@dudousxd/nestjs-durable-store-mikro-orm';

DurableModule.forRootAsync({
  inject: [MikroORM],
  useFactory: (orm: MikroORM) => ({
    store: new MikroOrmStateStore(orm),
    transport, // any transport — the store is independent of it
  }),
});

Each store operation runs on a forked EntityManager, so it owns its own unit of work and won't interfere with request-scoped EMs.

Tenant-scoped reads with withScope

MikroOrmStateStore is the only bundled adapter that implements withScope(scope) — derive a store confined to one tenant namespace, sharing the same underlying connection:

const tenantStore = store.withScope({ namespace: 'acme-corp' });

This is what powers DurableModule's scopeReads: true option: the module receives an already-built store and can't reconstruct it, so it asks for a scoped view through this optional capability. Reads (listRuns, listIncompleteRuns, getRun, …) go through a forked EntityManager with a namespace global filter active; writes bypass MikroORM global filters and are unaffected. Pass { namespace: undefined } to get back the unscoped operator view.

TypeORM, Prisma and Drizzle don't implement withScope — turning on scopeReads against any of them is a silent no-op (the store keeps returning every namespace's runs). This is currently a MikroORM-only capability.

Schema: auto on boot, or your own migration

By default the module calls store.ensureSchema() on boot, which runs MikroORM's schema.updateSchema({ safe: true }) — additive only, it never drops a column. Zero setup in dev.

In production, turn it off and own the schema from a migration. The package exports ensureMikroOrmDurableSchema(orm) so your migration applies the exact same additive update:

import { ensureMikroOrmDurableSchema } from '@dudousxd/nestjs-durable-store-mikro-orm';

export class AddDurableTables extends Migration {
  async up() {
    await ensureMikroOrmDurableSchema(this.getEntityManager().getOrm());
  }
}
disable auto-schema in production
DurableModule.forRoot({ store, transport, autoSchema: false });

It only ever adds durable_workflow_runs, durable_step_checkpoints, durable_run_attributes, durable_signal_waiters, durable_buffered_signals and durable_buffered_events — your tables are untouched.

The heal is fingerprint-gated

ensureMikroOrmDurableSchema runs on every boot of every pod, and a full heal means getUpdateSchemaSQL({ safe: true }) introspecting the whole database's information_schema (the store typically shares the host app's ORM) before filtering down to the durable tables — expensive to repeat on every steady-state boot. So a marker table, durable_schema_meta, records a SHA-256 fingerprint of the durable entity metadata last applied. On boot the module computes the current fingerprint and compares it to the stored one with two cheap round-trips (a CREATE TABLE IF NOT EXISTS for the marker plus one PK read); if they match, it returns immediately, skipping both the introspection and the collation check below.

Only a fresh database (no marker row yet), an actual entity change, or a hand-bumped internal schema-revision constant triggers the full heal — done under a best-effort advisory lock (MySQL GET_LOCK / Postgres pg_advisory_lock) so concurrent pods don't race the same DDL, with a re-check after acquiring in case a sibling pod already healed while this one waited.

Collation auto-convergence (MySQL/MariaDB)

MikroORM's auto-schema creates tables with the server's default collation (utf8mb4_0900_ai_ci on MySQL 8.4) and ignores the ORM's configured collate option. If your own tables are pinned to a different collation (commonly utf8mb4_unicode_ci via migrations), a JOIN between one of your tables and a durable table throws "Illegal mix of collations". The heal converges this automatically: after creating/extending the durable tables, it reads the ORM's configured collate, checks each durable table's current collation via information_schema.tables, and runs ALTER TABLE ... CONVERT TO CHARACTER SET ... COLLATE ... on any table that doesn't already match.

This is idempotent (already-aligned tables are skipped), non-fatal (a failed CONVERT is logged as a warning, never crashes boot), and a no-op on Postgres/SQLite or when no collate is configured. It only runs as part of a full heal — the fingerprint gate above skips it too on a steady-state boot.

On this page