Aviary
Packages

@dudousxd/nestjs-agent-store-mikro-orm

MikroORM AgentStore adapter — threads, messages, tool calls, usage, run reliability, and action proposals — with safe auto-schema.

The AgentStore SPI (from @dudousxd/nestjs-agent-core) backed by MikroORM. The store is a POJO that receives an EntityManager; every operation runs on a fresh em.fork() so per-request identity maps never bleed across concurrent turns.

pnpm add @dudousxd/nestjs-agent-store-mikro-orm @mikro-orm/core @mikro-orm/nestjs

Minimal example

forFeature() does not register the entities — add them to your own MikroORM config so they enter the shared ORM's metadata (the store's repositories and the boot heal read it):

import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { AGENT_ENTITIES, MikroOrmAgentStoreModule } from '@dudousxd/nestjs-agent-store-mikro-orm';
import { AgentModule, HeaderActorResolver } from '@dudousxd/nestjs-agent';

@Module({
  imports: [
    MikroOrmModule.forRoot({
      // …driver, dbName
      entities: [...yourEntities, ...AGENT_ENTITIES], // agentEntities() with no collation on SQLite/Postgres
    }),
    MikroOrmAgentStoreModule.forFeature(), // binds AGENT_STORE, AGENT_GOVERNANCE_QUERIES, AGENT_PRICING_STORE, AGENT_CONFIRM_TOKEN_STORE
    AgentModule.forRoot({
      model: myModelProvider,
      // no `store` — the agent reads it from AGENT_STORE
      actorResolver: new HeaderActorResolver(),
    }),
  ],
})
export class AppModule {}

forFeature(options?):

OptionDefault
autoSchematrueRun ensureAgentSchema(orm) on application bootstrap. false when your own migrations own these tables
pricingStoreMikroOrmPricingStoreA Type<AgentPricingStore> bound to AGENT_PRICING_STORE instead
ragIngestionLogtrueBind and export MikroOrmRagIngestionLog; false binds nothing

With autoSchema on, the tables are created and healed at boot. With it off, use MikroORM migrations (agentSchemaSql renders the DDL), or call the helper yourself:

import { ensureAgentSchema } from '@dudousxd/nestjs-agent-store-mikro-orm';

await ensureAgentSchema(orm); // fingerprint-gated: creates, adds columns, corrects MySQL collations

The boot heal owns the schema

With autoSchema on (the default) the store's boot heal is the whole schema story: it creates every agent_* table, adds the columns and indexes a later release declares, and keeps each column in the type and collation its entity declares. An app on this store writes no migrations for these tables (keep them out of your own migration differ with agentManagedTables()), and an upgrade needs nothing but a restart.

Collations are part of that. The entities declare one on every MySQL string column (AGENT_COLLATION, utf8mb4_unicode_ci, and the binary utf8mb4_bin on the values compared exactly: actor and tenant refs, memory scopes, proposal keys, channel state keys such as provider message ids). MikroORM before 7.2 renders no collate clause in create table / add column and does not diff collations, so an earlier heal left those columns in the table default (utf8mb4_0900_ai_ci on MySQL 8, case-insensitive): wamid.AbC and wamid.aBc were one key. The heal now reads the actual collation of every agent column and corrects the ones that differ, one alter table … modify per table with foreign-key checks off on that connection (a referenced key and the columns pointing at it change together, and the foreign keys still hold afterwards). The schema fingerprint covers collations, so a database healed by an earlier version gets the correction on the first boot after the upgrade, once.

The correction is safe on a populated table. Before altering anything it checks that no unique key would merge two rows under the declared collation ('abc' and 'ABC' going to a case-insensitive column) and that no child row matched its parent only case-insensitively. If either would happen it alters nothing, records no fingerprint, and throws AgentSchemaCollationError, naming the key and an example value: merge, rename or delete those rows, and the next boot corrects the columns. Postgres and SQLite name no collation on these columns (their comparisons are already exact), so there the heal has nothing to correct.

Upgrading from a release before Postgres/MySQL were tested for real: agent_message gains a nullable seq (per-thread append order); Postgres gains an index on agent_tool_call.message_id; on MySQL timestamps become datetime(6), long text columns longtext, and identity columns (actor_ref, tenant_ref, memory scope/origin_actor_ref) the binary collation utf8mb4_bin. The MySQL modify statements rebuild the tables once — on a large deployment, boot with forFeature({ autoSchema: false }) and run ensureAgentSchema(orm) from a one-off script in a maintenance window.

Upgrading from a release before reasoning was persisted: agent_message gained three nullable columns. ensureAgentSchema adds them; migration:create picks them up from the entity diff; or by hand:

alter table agent_message add column reasoning text null;
alter table agent_message add column reasoning_ms integer null;
alter table agent_message add column ui json null;

Upgrading from a release before personas: three more nullable columns, picked up the same three ways.

alter table agent_thread add column persona varchar(255) null;
alter table agent_message add column persona varchar(255) null;
alter table agent_queued_message add column persona varchar(255) null;

Upgrading from a release before per-call confirmations and independent approvals: nullable agent_tool_call.confirmation (json) and agent_tool_call.proposal_id, agent_message.action_proposal_outcome (escaped JSON text), agent_queued_message.ui_capabilities (json), and the new agent_action_proposal table. Same three ways; see Action proposals for the rollout order.

MikroOrmAgentStoreModule.forFeature() is global

It binds AGENT_STORE, AGENT_GOVERNANCE_QUERIES, AGENT_PRICING_STORE and AGENT_CONFIRM_TOKEN_STORE on a global Nest module. Import it once — AgentModule.forRoot({...}) (and the dashboard/telescope surfaces) resolve these tokens app-wide with no re-binding. Do not import it more than once expecting scoped behavior.

Exports

ExportKindPurpose
MikroOrmAgentStoreModuleDynamicModule factory.forFeature({ autoSchema?, pricingStore?, ragIngestionLog? }) binds AGENT_STORE + AGENT_GOVERNANCE_QUERIES + AGENT_PRICING_STORE + AGENT_CONFIRM_TOKEN_STORE globally, and heals the schema at boot unless autoSchema: false. Does not register entities
MikroOrmAgentStoreOptionstypeThe forFeature options
MikroOrmAgentStoreclassAgentStore implementation — the write side (threads, messages, tool calls, usage), including updateThread (title, the thread's defaultAgent, model and/or persona), the projected defaultAgentForThread/modelForThread/personaForThread and loadThreadForTurn reads (see what a turn reads off a thread), referencedMediaIds (which media a live message still carries — the library half of attachment collection), the optional recordRunStart/recordRunEnd/bumpRunRetries run-reliability hooks, the ChatQueueStore message queue, and every action-proposal capability (see below). new MikroOrmAgentStore(em, { clock? })
MikroOrmMemoryProviderclassMemoryProvider implementation over agent_memory — list (scope-gated in the query), write (upsert on the (scope, key) unique index, with pinned/created_at/id out of the conflict merge), forget (the actor's own scope in the where), plus pin({ id, pinned }) for the console. No search — see Memory
MikroOrmGovernanceQueriesclassAgentGovernanceQueries implementation — the full read-model: spend by model/actor/thread, usage trend, recent activity, run reliability (runMetrics/runsByAgent/runErrors/runTrend/recentRuns), the approvals inbox (pendingApprovals), per-tool stats (toolStats), and paged/filterable reads (toolCallsPage/threadsPage/runsPage)
MikroOrmRagIngestionLogclassRecords every RAG ingestion outcome into rag_ingestion_log by subscribing to the aviary:rag:* diagnostics — the only place a skipped or failed document is visible, since one that produced no chunks does not exist as far as VectorStore.listDocuments() is concerned. Reads: list/listPage (a page plus the unpaginated total), get, remove, removeByCollection, the delete-safe keyset iterate, and listDocumentIds for an orphan sweep. On by default; forFeature({ ragIngestionLog: false }) binds nothing
MikroOrmPricingStoreclassAgentPricingStore implementation — upsertModelPrice / listCurrentPrices, the write side of model pricing (see Cost & Governance)
MikroOrmTokenStreamSinkclassA multi-replica TokenStreamSink over agent_stream_frame — several replicas with no Redis. new MikroOrmTokenStreamSink(em, options?); pass it as AgentModule's sink. Not bound by the module. Polled, text coalesced at write time, TTL from the last write; see the live stream across replicas
MikroOrmConfirmTokenStoreclassConfirmTokenStore over agent_confirm_token — what makes a confirmed write single use across replicas. claim is one insert that skips on a duplicate hash (the primary key is the lock), release, purgeExpired(now?). Bound to AGENT_CONFIRM_TOKEN_STORE by MikroOrmAgentStoreModule
MikroOrmChannelStoreclassChannelStore over agent_channel_state — what text channels keep their message ids, questions in progress and relayed outcomes in, shared by every replica. claim(key, ttlMs) is one insert that skips on a duplicate key (the primary key is the lock), get, set, delete, purgeExpired(now?); new MikroOrmChannelStore(em, { purgeEveryMs? }). Bound to AGENT_CHANNEL_STORE by MikroOrmAgentStoreModule
MikroOrmStreamFrameTableclassThe StreamFrameTable behind MikroOrmTokenStreamSink
ensureAgentSchema(orm)functionFingerprint-gated, non-destructive heal (getUpdateSchemaSQL({ safe: true }), agent tables only) — create + add-column, never drop or alter. Re-diffs afterwards and throws AgentSchemaHealError rather than record a heal that did not apply
agentSchemaSql(ormOrConfig, { ifNotExists?, collation? }?)function → Promise<string[]>The agent DDL rendered from entity metadata in your dialect, without touching the live database — for a MikroORM Migration (agentSchemaSql(this.config)). ifNotExists defaults to true; collation defaults to the one your registered entities carry
AgentSchemaHealErrorclassThrown when structure the diff asked for is still pending after the heal; carries the pending statements. The schema fingerprint is NOT recorded, so the next boot retries
agentManagedTables()function → string[]Every table this store owns, for a host's own MikroORM schema-diff skipTables — mirrors durableManagedTables()/telescopeManagedTables()
AGENT_ENTITIESEntitySchema[]The agent entity schemas, pre-stamped with utf8mb4_unicode_ci for MySQL parity
agentEntities({ collation? })functionBuilds the entity schemas with a custom (or no) collation, e.g. for SQLite
AGENT_COLLATION'utf8mb4_unicode_ci'The default collation baked into AGENT_ENTITIES
entity classes (AgentActionProposal, AgentThread, AgentMessage, AgentQueuedMessage, AgentToolCall, AgentTokenUsage, AgentModelPricing, AgentRun, AgentMemory, RagIngestionLog, AgentStreamFrame, AgentConfirmToken, AgentChannelState)EntitySchema-backed classesThe tables this store owns — agent_action_proposal, agent_thread, agent_message, agent_queued_message, agent_tool_call, agent_token_usage, agent_model_pricing, agent_run, agent_memory, rag_ingestion_log, agent_stream_frame, agent_confirm_token, agent_channel_state. Each has a …Schema(collation?) builder (agentThreadSchema, …)
custom repositories (AgentThreadRepository, AgentMessageRepository, AgentToolCallRepository, … one per entity)EntityRepository subclassesWired through each schema's repository option and [EntityRepositoryType], so em.getRepository(AgentThread) is typed as AgentThreadRepository and a host can inject them by type
RAG_INGESTION_LOG_PAGE_ORDERconstThe rag_ingestion_log paging order (updatedAt desc, documentId asc)

referencedMediaIds needs no migration

referencedMediaIds(actorRef, mediaIds) — the library half of attachment collection — reads the attachments JSON column agent_message has carried since attachments shipped, so an existing database answers correctly the moment you upgrade. No new table, no column, no backfill, and no boot-time heal in the path at all. The match runs in memory rather than in SQL (the column holds an array of objects, no dialect indexes that), bounded to one actor's attachment-bearing messages (a message still waiting in a thread's queue counts too). A soft-deleted thread still counts as holding its references: the rows survive, so the bytes are still reachable.

How the add-column heal works on SQLite

The heal applies getUpdateSchemaSQL({ safe: true }) and keeps the statements whose every named table is an agent table. On MySQL and Postgres a new column is a plain alter table … add column. On SQLite/libsql MikroORM expresses the same change as a table rebuild (create table agent_thread__temp_alter → copy → drop → rename), which is kept as a unit and run with foreign-key enforcement off, restored afterwards — dropping agent_thread with enforcement on fires the children's on delete cascade and would take every message with it.

The heal then re-diffs and throws AgentSchemaHealError if required structure is still pending, and the schema fingerprint is written only after it returns. A heal the database refused (no DDL grant, a managed schema) therefore fails the boot with the pending statements in the message instead of being recorded as applied — apply them with agentSchemaSql in a migration.

rag_ingestion_log — the ingest ledger, and why it carries no agent_ prefix

@dudousxd/nestjs-agent-rag-media owns no storage: it publishes aviary:rag:* diagnostics, and a store subscribes. MikroOrmRagIngestionLog is this adapter's subscriber, which is why this store creates and heals a table named outside the agent_ family. store-drizzle carries the same capability as DrizzleRagIngestionLog — the choice of ORM adapter does not decide whether a deployment can audit its ingestions.

The table is the only place a document that produced no chunks is visible at all: an extraction that came back empty, a mime type with no extractor, an embedding call that threw. VectorStore.listDocuments() knows only about successes, so without this table a scanned PDF that silently failed to index reads exactly like one nobody ever uploaded.

MikroOrmAgentStoreModule.forFeature() binds and exports it by default; { ragIngestionLog: false } binds nothing at all, for a host that records outcomes itself or ingests no media.

agent_memory — the library owns this one, and not a skill table

Memory rows live in agent_memory, created and healed like the rest. That looks inconsistent next to skills, whose rows are always the host's, and the difference is what a row can be related to: a skill plausibly joins your own entities (who authored it, which depot, which review approved it), while a memory is a key, a fact, an opaque scope token and an origin. Every other part of memory was already the library's — the remember tool, writeMemory, the memory:digest checkpoint, and the read-back and delete endpoints — so storage was the only half a consumer had to invent.

MikroOrmAgentStoreModule.forFeature() exports MikroOrmMemoryProvider but never binds AGENT_MEMORY: that token's presence is what turns memory on, so binding it would give the feature to every host that installed the store.

agent_run — the reliability + approvals table

Every turn is recorded as a run in agent_run (status, duration, error code/message, retry count, a promptHash for correlating error-rate shifts with prompt changes, and parent_run_id — the run that delegated this one, so a delegation's cost rolls up to the turn that asked for it even when the child is detached and outlives that turn). MikroOrmGovernanceQueries reads it for the dashboard's Reliability section and for pendingApprovals/toolStats — created and healed by ensureAgentSchema like every other table here, no separate wiring needed.

Action proposals (independent approvals)

MikroOrmAgentStore implements everything actionApprovalMode: 'independent' checks for at boot — ActionProposalStore, ActionProposalWorkerStore, ActionProposalDiscoveryIndexStore, outcome admission, atomic replacement, and the ChatQueueStore queue — on SQLite, Postgres and MySQL. No extra registration: AGENT_STORE is already the proposal store. See Action proposals.

  • Entity. AgentActionProposal (agent_action_proposal) keeps the decision, its audit and the execution work in one row, updated by compare-and-swap on revision. SHA-256 physical keys; every read and mutation checks the exact tenant, actor and thread scope. No foreign keys to chat rows. Passing a transactional EntityManager keeps proposal operations inside the caller's transaction.
  • Worker discovery. claimNextActionProposal({ workerId, leaseMs }) claims queued or expired-lease work across scopes (at most 32 candidates per call); expireActionProposals({ limit }) expires due pending proposals (limit 1–1000). Privileged — never expose them on user routes.
  • Outcomes. Admission locks the proposal and thread and inserts one assistant message carrying agent_message.action_proposal_outcome together with the delivery state, in one transaction.
  • SQLite. Writes to one database file are also serialized in-process, so an async transaction does not block the event loop; database locks and version CAS remain the cross-replica authority.

Rollout order for an existing database

Existing proposal rows get discovery_index_version = 0; workers only discover version-1 rows. Mixing older writers with active discovery is unsupported.

  1. Stop writers running the older package version.

  2. Apply the schema — ensureAgentSchema (the autoSchema boot heal), or a migration generated from the updated entities: proposal discovery/delivery/replacement columns and indexes, agent_message.action_proposal_outcome, agent_tool_call.proposal_id, agent_queued_message.ui_capabilities.

  3. Repeat the bounded backfill until it returns 0:

    while (await store.backfillActionProposalDiscoveryIndex({ limit: 1000 })) {
      // each batch returns the number of rows it upgraded
    }

    limit is 1–1000. Each row is updated under its observed revision and the version-zero fence; payload, audit, idempotency key and timestamps are preserved. Run it outside long-lived snapshot transactions. Discovery never backfills implicitly.

  4. Deploy the new writers and workers, then enable actionApprovalMode: 'independent'.

Peer dependencies

@mikro-orm/core (^6 \|\| ^7), @mikro-orm/nestjs (^6 \|\| ^7), and @nestjs/common (^10 \|\| ^11 \|\| ^12) — plus your driver package (@mikro-orm/postgresql, @mikro-orm/mysql, …).

When to use it

Reach for this adapter when MikroORM is already your app's ORM — MikroOrmModule.forRoot(...) owns the connection, your config registers the agent's entities alongside your own, and forFeature() binds the stores. If you'd rather bring an already-opened handle and skip MikroORM entirely, see store-drizzle.

  • Persistence — the AgentStore SPI, the bound tokens, and choosing an adapter
  • Action proposals — independent approvals on top of this store
  • Cost & Governance — the read-model this adapter feeds, and the ledger cost formula

On this page