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:
| Concern | Table | SPI methods |
|---|---|---|
| Threads | agent_thread | createThread, getThread, listThreads, softDeleteThread, forkThread, setTitle, setActiveStream, ownerOfThread, ownerOfActiveStream |
| Messages | agent_message | appendMessage, truncateFrom |
| Tool calls | agent_tool_call | recordToolCall, updateToolCall, ownerOfToolCall |
| Token usage (ledger) | agent_token_usage | recordUsage, quotaToday |
| Model pricing | agent_model_pricing | read 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:
| Token | Bound to | Consumed by |
|---|---|---|
AGENT_STORE | the store (MikroOrmAgentStore / DrizzleAgentStore) | the agent module — every turn reads it |
AGENT_GOVERNANCE_QUERIES | the read-model (…GovernanceQueries) | the dashboard & Telescope surfaces |
AGENT_PRICING_STORE | the 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/nestjsMikroOrmAgentStoreModule.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 defaultThe 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-ormThe 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.
| MikroORM | Drizzle | |
|---|---|---|
| Import | MikroOrmAgentStoreModule.forFeature() | DrizzleAgentStoreModule.forRoot({ db }) |
| Connection | MikroORM owns it (forRoot) | host owns it, passes db in |
| Schema | MikroORM migrations (or ensureAgentSchema) | drizzle-kit migrations (or ensureAgentSchema) |
| Dialects | whatever your MikroORM driver supports | SQLite-dialect (better-sqlite3, libsql, D1, …) |
| Binds | AGENT_STORE + AGENT_GOVERNANCE_QUERIES + AGENT_PRICING_STORE | AGENT_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.
Related
- Getting Started — install, declare a tool, and stream your first turn
- Cost & Governance — the usage ledger,
AGENT_GOVERNANCE_QUERIES, and the console - Human-in-the-loop & Durability — when a suspended run is checkpointed to the store
- Identity & Authorization — the actor whose ref keys every thread and usage row
Cost & Governance
The usage ledger and read-model — spend per model and actor, a gateway's reported cost preferred over a cache-aware estimate, daily quota, and the console that surfaces it all.
Frontend
Wire a React chat UI to the agent with useAgentChat — the AI SDK v7 transport, threads, quota, cancel, HITL approve/reject, and styling-agnostic components.