Aviary
Guides

Persistence

The AgentStore SPI — threads, messages, tool calls, and a token-usage ledger — behind one ORM-portable interface, wired on MikroORM or Drizzle.

Persistence is the write half of the agent: every turn creates or continues a thread, appends messages, records each tool call, and writes a row to a token-usage ledger. All of that goes through one interface — the AgentStore SPI — so the agent loop never knows which ORM (or database) is underneath. The library ships two adapters that implement the exact same SPI: MikroORM and Drizzle. Pick one, wire it, and the agent reads it through a DI token.


The AgentStore SPI

AgentStore (from @dudousxd/nestjs-agent-core) is ORM-agnostic — refs are string ids, and an adapter is free to back them with real relations. It covers five concerns across five tables:

ConcernTableSPI methods
Threadsagent_threadcreateThread, getThread, listThreads, softDeleteThread, forkThread, setTitle, setActiveStream, ownerOfThread, ownerOfActiveStream
Messagesagent_messageappendMessage, truncateFrom
Tool callsagent_tool_callrecordToolCall, updateToolCall, ownerOfToolCall
Token usage (ledger)agent_token_usagerecordUsage, quotaToday
Model pricingagent_model_pricingread by the governance read-model; written by the separate AgentPricingStore SPI (upsertModelPrice, listCurrentPrices) — see Cost & Governance
// the shape both adapters implement
export interface AgentStore {
  createThread(input: CreateThreadInput): Promise<ThreadSummary>;
  getThread(threadId: string): Promise<ThreadDetail | null>;
  listThreads(actorRef: string, limit?: number): Promise<ThreadSummary[]>;
  softDeleteThread(threadId: string): Promise<void>;
  forkThread(threadId: string, fromMessageId: string): Promise<ThreadSummary>;
  appendMessage(input: AppendMessageInput): Promise<StoredMessage>;
  recordToolCall(input: RecordToolCallInput): Promise<void>;
  recordUsage(input: RecordUsageInput): Promise<void>;
  quotaToday(actorRef: string, day: string): Promise<{ usedTokens: number }>;
  ownerOfThread(threadId: string): Promise<string | null>;
  ownerOfToolCall(toolCallId: string): Promise<string | null>;
  runForToolCall(toolCallId: string): Promise<string | null>;
  ownerOfActiveStream(runId: string): Promise<string | null>;
  // …setTitle, setActiveStream, truncateFrom, updateToolCall
}

ownerOfThread, ownerOfToolCall, and ownerOfActiveStream resolve the owning actorRef (or null if the row doesn't exist) — the governance endpoints (/agent/threads/:id, /agent/tool-call/approve · /reject, /agent/chat/:runId/cancel) call these to assert the acting actor owns the target before acting on it. runForToolCall resolves the tool call's thread's active stream — the run currently awaiting it — so approve/reject route to the exact run (including a delegated sub-agent's own child run) without a client-supplied run id.

Each usage row carries the cache-aware token counts (inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) and an optional provider-reported costUsd — the raw material the Cost & Governance read-model rolls up.


Run reliability recording (optional)

Beyond the five tables above, AgentStore carries three optional methods for a sixth concern — run outcomes, in agent_run — absent on an adapter, they're a graceful no-op, so an existing custom AgentStore keeps compiling untouched:

recordRunStart?(run: RecordRunStartInput): Promise<void>; // carries promptHash
recordRunEnd?(end: RecordRunEndInput): Promise<void>;     // durationMs, errorCode/errorMessage on failure
bumpRunRetries?(runId: string): Promise<void>;

The loop calls recordRunStart at the top of a turn as a checkpointed step, carrying the sha256 of the resolved system prompt as promptHash — computed pre-RAG, so it identifies the prompt version rather than the per-request, retrieval-augmented text, letting you correlate error-rate shifts with prompt changes. recordRunEnd is called on completion (with duration) and on failure (with an error code/message) by both runners — the durable workflow and the inline runner alike.

Both bundled adapters (-store-mikro-orm, -store-drizzle) implement all three against a new agent_run table (autoSchema-managed, alongside the five tables above), and the same rows back AgentGovernanceQueries' reliability reads — runMetrics, runsByAgent, runErrors, runTrend, recentRuns (which surfaces each run's promptHash) — plus a paginated runsPage. See Cost & Governance for what those reads render.


Three tokens, bound together

An adapter binds three DI tokens — the write side, the read side, and the pricing writer — so wiring one module gives you persistence, analytics, and priced cost estimates:

TokenBound toConsumed by
AGENT_STOREthe store (MikroOrmAgentStore / DrizzleAgentStore)the agent module — every turn reads it
AGENT_GOVERNANCE_QUERIESthe read-model (…GovernanceQueries)the dashboard & Telescope surfaces
AGENT_PRICING_STOREthe pricing writer (MikroOrmPricingStore / DrizzlePricingStore)your app, to seed/update model prices — see Cost & Governance

All three are Symbol.for(...) tokens from @dudousxd/nestjs-agent-core (global-registry symbols, so DI survives pnpm's dual ESM/CJS copies). A store module binds them globally (a Nest global module), so any module in the app resolves them without re-importing. That's why store on AgentModule.forRoot({ … }) is optional: import a store module and the agent resolves the store from the app-wide AGENT_STORE — leave store off entirely. Pass store only for a store you don't bind through a module (the in-memory testing store, say).

One import, read + write + pricing

Importing a store module binds AGENT_GOVERNANCE_QUERIES and AGENT_PRICING_STORE as well as AGENT_STORE. That's why the governance console and Telescope tab "just work" once persistence is wired — they read the same adapter's read-model. Cost estimates are the exception: they stay $0 until something writes to AGENT_PRICING_STORE, since a fresh pricing table has no rows to read. See Cost & Governance.


Wiring an adapter

The same SPI, two ORMs. MikroORM registers entities and leans on MikroORM migrations; Drizzle takes an already-opened db handle that the host owns.

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

MikroOrmAgentStoreModule.forFeature() registers the five agent entities (EntitySchema) and binds all three tokens. It needs a MikroOrmModule.forRoot(...) in scope for the EntityManager.

import { MikroOrmModule } from '@mikro-orm/nestjs';
import { MikroOrmAgentStoreModule } from '@dudousxd/nestjs-agent-store-mikro-orm';
import { AgentModule } from '@dudousxd/nestjs-agent';

@Module({
  imports: [
    MikroOrmModule.forRoot(/* your config */),
    MikroOrmAgentStoreModule.forFeature(), // registers entities + binds AGENT_STORE (+ queries + pricing)
    AgentModule.forRoot({
      model: myModelProvider,
      // no `store` — the agent reads it from AGENT_STORE
      defaultRoles: ['ADMIN'],
      actorResolver: new HeaderActorResolver(),
    }),
  ],
})
export class AppModule {}

Create the tables with your normal MikroORM migrations. For a quick start (or a shared, multi-owner DB), the exported ensureAgentSchema helper runs a non-destructive schema.update({ safe: true }) — create + add-column only, never a drop or alter:

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

await ensureAgentSchema(orm); // safe: true by default

The entity set is exported too: AGENT_ENTITIES (stamped with utf8mb4_unicode_ci for MySQL parity) and agentEntities({ collation }) if you need to omit the collation for SQLite.

pnpm add @dudousxd/nestjs-agent-store-drizzle drizzle-orm

The host app opens the connection and hands DrizzleAgentStoreModule.forRoot({ db }) an already-opened Drizzle handle — the module never opens one itself. Any SQLite-dialect driver works (better-sqlite3, libsql, D1, …).

import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import {
  DrizzleAgentStoreModule,
  agentSchema,
  ensureAgentSchema,
} from '@dudousxd/nestjs-agent-store-drizzle';
import { AgentModule } from '@dudousxd/nestjs-agent';

const db = drizzle(new Database('app.db'), { schema: agentSchema });
await ensureAgentSchema(db); // idempotent CREATE TABLE IF NOT EXISTS — or run your own migrations

@Module({
  imports: [
    DrizzleAgentStoreModule.forRoot({ db }), // binds AGENT_STORE + AGENT_GOVERNANCE_QUERIES + AGENT_PRICING_STORE
    AgentModule.forRoot({
      model: myModelProvider,
      defaultRoles: ['ADMIN'],
      actorResolver: new HeaderActorResolver(),
    }),
  ],
})
export class AppModule {}

The package ships agentSchema (the Drizzle tables), ensureAgentSchema (the CREATE TABLE IF NOT EXISTS helper), DrizzleAgentStore, DrizzleGovernanceQueries, and DrizzlePricingStore.

ensureAgentSchema is a quick start, not a migration story

ensureAgentSchema never drops or alters an existing column, so it can't evolve a table once it exists. For production, prefer drizzle-kit migrations over the helper — treat ensureAgentSchema as the "stand it up with no migration files" path (tests, demos, first boot).


Choosing an adapter

Both implement the identical SPI, so the agent behaves the same either way — the choice is about the rest of your stack.

MikroORMDrizzle
ImportMikroOrmAgentStoreModule.forFeature()DrizzleAgentStoreModule.forRoot({ db })
ConnectionMikroORM owns it (forRoot)host owns it, passes db in
SchemaMikroORM migrations (or ensureAgentSchema)drizzle-kit migrations (or ensureAgentSchema)
Dialectswhatever your MikroORM driver supportsSQLite-dialect (better-sqlite3, libsql, D1, …)
BindsAGENT_STORE + AGENT_GOVERNANCE_QUERIES + AGENT_PRICING_STOREAGENT_STORE + AGENT_GOVERNANCE_QUERIES + AGENT_PRICING_STORE

Reach for the adapter that matches the ORM already in your app. The SPI is the portability guarantee: if you migrate ORMs later, only the store module import changes — the agent, the tools, and the governance surfaces are untouched.


In-memory store for tests

For offline tests and demos, @dudousxd/nestjs-agent-testing ships an InMemoryAgentStore (plus InMemoryGovernanceQueries, InMemoryPricingStore, an InMemoryQuotaStore, InMemoryTokenStreamSink, and a deterministic fake model). No database, no migrations — the whole loop (auto-executing reads, a suspending action tool, multi-agent delegation) runs with zero infrastructure.

import { InMemoryAgentStore } from '@dudousxd/nestjs-agent-testing';

AgentModule.forRoot({
  model: fakeModel,
  store: new InMemoryAgentStore(), // pass it directly instead of importing a store module
  defaultRoles: ['ADMIN'],
  actorResolver: new HeaderActorResolver(),
});

The repo's examples/agent-demo is a runnable proof built entirely on these in-memory pieces — see Getting Started for the pnpm --filter agent-demo demo command.


On this page