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-ormIt 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:
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:
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());
}
}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.
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).
TypeORM
The TypeORM StateStore adapter. Register the durable entities on your DataSource, pass it to TypeOrmStateStore, and run on Postgres, MySQL/MariaDB or SQLite — with auto-schema on boot or an ensureTypeOrmDurableSchema helper for your own migration.