Aviary
Packages

@dudousxd/nestjs-agent-store-drizzle

Drizzle AgentStore adapter — threads, messages, tool calls, usage, run reliability, and action proposals — over an app-owned SQLite, Postgres or MySQL handle.

The AgentStore SPI (from @dudousxd/nestjs-agent-core) backed by Drizzle. Unlike the MikroORM adapter, this package never opens a connection itself — the host app owns and hands it an already-opened Drizzle db.

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

Minimal example

SQLite, Postgres and MySQL all work. Build the handle with your dialect's schema object (agentSchema · pgAgentSchema · mysqlAgentSchema — same table, column and property names); every store reads the dialect off the handle. CI runs every store suite on better-sqlite3, node-postgres (Postgres 16) and mysql2 (MySQL 8.4).

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

const db = drizzle(new Database('app.db'), { schema: agentSchema });
await ensureAgentSchema(db); // creates what is missing, never drops — or run your own migrations

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

Postgres and MySQL take the same module; only the handle differs:

// Postgres
import { drizzle } from 'drizzle-orm/node-postgres';
import { pgAgentSchema } from '@dudousxd/nestjs-agent-store-drizzle';
const db = drizzle(pool, { schema: pgAgentSchema });

// MySQL (8.0+)
import { drizzle } from 'drizzle-orm/mysql2';
import { mysqlAgentSchema } from '@dudousxd/nestjs-agent-store-drizzle';
const db = drizzle(pool, { schema: mysqlAgentSchema, mode: 'default' });
SQLitePostgresMySQL
JSON columnstext (JSON)jsonbjson
timestampsepoch-ms integertimestamptz(3)datetime(3), UTC
long text (content, reasoning, errors)texttextlongtext
prices / costsrealdouble precisiondouble
id / actor comparisonexactexactexact (utf8mb4_bin)

forRoot options: db (required), pricingStore (a Type<AgentPricingStore> bound to AGENT_PRICING_STORE instead of DrizzlePricingStore), ragIngestionLog (default true).

DrizzleAgentStoreModule.forRoot() is global

Like the MikroORM adapter, this module binds AGENT_STORE, AGENT_GOVERNANCE_QUERIES, AGENT_PRICING_STORE and AGENT_CONFIRM_TOKEN_STORE on a global Nest module. Import it once — the agent, dashboard, and telescope surfaces resolve these tokens app-wide with no re-binding.

ensureAgentSchema is a quick start, not a migration story

It is additive only: the DDL is rendered from the dialect's schema object, so it creates any missing table, adds any column or index a running database lacks, and never drops a column or changes a type. On Postgres and MySQL it holds a cross-replica lock (pg_advisory_xact_lock / get_lock) so replicas booting an empty database at once do not race their CREATE TABLEs; on Postgres the whole heal is one transaction. It runs only when you call it — the module does not call it for you.

Prefer drizzle-kit migrations in production: point drizzle-kit at your dialect's schema object, or emit agentSchemaDdl(dialect) (the full CREATE TABLE / CREATE INDEX list). On your own migrations, the additive statements are yours to write — on SQLite, for a database that predates them:

ALTER TABLE agent_thread ADD COLUMN default_agent TEXT;
ALTER TABLE agent_message ADD COLUMN run_id TEXT;
ALTER TABLE agent_message ADD COLUMN attachments TEXT;
ALTER TABLE agent_run ADD COLUMN parent_run_id TEXT;
ALTER TABLE agent_message ADD COLUMN reasoning TEXT;
ALTER TABLE agent_message ADD COLUMN reasoning_ms INTEGER;
ALTER TABLE agent_message ADD COLUMN ui TEXT;
CREATE INDEX agent_tool_call_message_idx ON agent_tool_call (message_id);
ALTER TABLE agent_message ADD COLUMN feedback TEXT;
ALTER TABLE agent_message ADD COLUMN seq INTEGER;
ALTER TABLE agent_thread ADD COLUMN model TEXT;
ALTER TABLE agent_thread ADD COLUMN queue_pause TEXT;
-- approval policies
ALTER TABLE agent_tool_call ADD COLUMN approver TEXT;
ALTER TABLE agent_tool_call ADD COLUMN expires_at INTEGER;
ALTER TABLE agent_tool_call ADD COLUMN remember INTEGER;
ALTER TABLE agent_tool_call ADD COLUMN decided_via TEXT;
-- personas
ALTER TABLE agent_thread ADD COLUMN persona TEXT;
ALTER TABLE agent_message ADD COLUMN persona TEXT;
ALTER TABLE agent_queued_message ADD COLUMN persona TEXT;
-- per-call confirmations (action preflight)
ALTER TABLE agent_tool_call ADD COLUMN confirmation TEXT;
-- independent approvals
ALTER TABLE agent_tool_call ADD COLUMN proposal_id TEXT;
ALTER TABLE agent_message ADD COLUMN action_proposal_outcome TEXT;
ALTER TABLE agent_queued_message ADD COLUMN ui_capabilities TEXT;

Whole tables added since (agent_queued_message, agent_stream_frame, agent_confirm_token, agent_channel_state, agent_action_proposal) come from agentSchemaDdl(dialect) or the schema object. On Postgres the JSON columns are jsonb, on MySQL json.

Exports

ExportKindPurpose
DrizzleAgentStoreModuleDynamicModule factory.forRoot({ db }) binds AGENT_STORE + AGENT_GOVERNANCE_QUERIES + AGENT_PRICING_STORE + AGENT_CONFIRM_TOKEN_STORE globally over your db handle
DrizzleAgentStoreclassAgentStore implementation — the write side, 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 loop and durable runners call, the ChatQueueStore message queue, and every action-proposal capability independent approvals needs (see below). new DrizzleAgentStore(db, { clock? })
DrizzleActionProposalStoreclassThe proposal capabilities alone (ActionProposalStore, ActionProposalWorkerStore, ActionProposalDiscoveryIndexStore, ActionProposalSupersessionStore) over agent_action_proposal, for a host that needs proposals without the rest of the store. new DrizzleActionProposalStore(db, { clock? }). Not bound by the module — DrizzleAgentStore delegates to one internally
DrizzleMemoryProviderclassMemoryProvider implementation over agent_memory — list (scope-gated in the query), write (upsert on the (scope, key) unique index, whose conflict set names exactly what a rewrite may change, so pinned/created_at/id are left alone), forget (the actor's own scope in the where), plus pin({ id, pinned }) for the console. No search — see Memory
DrizzleGovernanceQueriesclassAgentGovernanceQueries 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)
DrizzleRagIngestionLogclassRecords 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; forRoot({ db, ragIngestionLog: false }) binds nothing
DrizzlePricingStoreclassAgentPricingStore implementation — upsertModelPrice / listCurrentPrices, the write side of model pricing (see Cost & Governance)
DrizzleTokenStreamSinkclassA multi-replica TokenStreamSink over agent_stream_frame — several replicas with no Redis. new DrizzleTokenStreamSink(db, options?) (SqlTokenStreamSinkOptions); 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
DrizzleConfirmTokenStoreclassConfirmTokenStore 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 DrizzleAgentStoreModule
DrizzleChannelStoreclassChannelStore 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 DrizzleChannelStore(db, { purgeEveryMs? }). Bound to AGENT_CHANNEL_STORE by DrizzleAgentStoreModule
DrizzleStreamFrameTableclassThe StreamFrameTable behind DrizzleTokenStreamSink, for composing your own SqlTokenStreamSink
agentSchemaDrizzle schema objectThe SQLite tables this store owns — agentActionProposal, agentThread, agentMessage, agentQueuedMessage, agentToolCall, agentTokenUsage, agentModelPricing, agentRun, agentMemory, ragIngestionLog, agentConfirmToken, agentStreamFrame. Each table is also exported on its own
pgAgentSchema / mysqlAgentSchemaDrizzle schema objectThe same tables, keys and property names in Postgres / MySQL types
ensureAgentSchema(db)functionNon-destructive heal for the handle's dialect: creates missing tables, adds missing columns and indexes, never drops or alters. Cross-replica lock on Postgres/MySQL
agentSchemaDdl(dialect)function → string[]The CREATE TABLE IF NOT EXISTS / CREATE INDEX statements for 'sqlite' | 'postgres' | 'mysql', in dependency order — for a hand-written migration
agentDialectOf(db)functionWhich dialect a handle speaks: 'sqlite' | 'postgres' | 'mysql'
AgentDrizzleDbtypeThe db handle the module and stores take — AgentSqliteDb | AgentPgDb | AgentMySqlDb (all exported), any driver of those dialects
AgentDialecttype'sqlite' | 'postgres' | 'mysql'
DrizzleAgentStoreModuleOptionstype{ db, pricingStore?, ragIngestionLog? }
RAG_INGESTION_LOG_PAGE_ORDERSQL[]The rag_ingestion_log paging order (updated_at desc, tiebroken on the key)
AgentRunRow / AgentThreadRow / AgentMessageRow / AgentQueuedMessageRow / AgentMemoryRow / RagIngestionLogRowtype$inferSelect row shapes (SQLite schema)

referencedMediaIds needs no migration

It 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. 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.

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

Memory rows live in agent_memory, created by ensureAgentSchema 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, while a memory is a key, a fact, an opaque scope token and an origin. origin_thread_id is the one column here that is deliberately not a foreign key — a memory outlives the conversation it came from, and a cascade off agent_thread would delete beliefs when a transcript aged out.

DrizzleAgentStoreModule.forRoot({ db }) exports DrizzleMemoryProvider 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.

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. DrizzleRagIngestionLog is this adapter's subscriber, which is why ensureAgentSchema creates a table named outside the agent_ family, indexed by (collection, updated_at) for the per-collection listing. store-mikro-orm carries the same capability as MikroOrmRagIngestionLog — 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.

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

agent_run — the reliability + approvals table

Every turn is recorded as a run in agentRun (status, duration, error code/message, retry count, a promptHash for correlating error-rate shifts with prompt changes). DrizzleGovernanceQueries reads it for the dashboard's Reliability section and for pendingApprovals/toolStats — no separate table or wiring needed, it's created by ensureAgentSchema like every other table here.

agent_tool_call.confirmation — per-call confirmation wording

An action's preparation preflight can return its own confirmation wording for one call. It is stored in the nullable JSON column agent_tool_call.confirmation and returned in StoredMessage.approvals, so a reload shows the same card text. ensureAgentSchema adds the column; on your own migrations add it before upgrading.

Action proposals (independent approvals)

DrizzleAgentStore implements everything actionApprovalMode: 'independent' checks for at boot — ActionProposalStore, ActionProposalWorkerStore, ActionProposalOutcomeStore, ActionProposalSupersessionStore, ActionProposalDiscoveryIndexStore, and the ChatQueueStore message queue — so the module's AGENT_STORE needs no extra binding. See Action proposals.

  • Table. agent_action_proposal holds the canonical proposal snapshot, its decision and its execution work in one row, fenced by a numeric version. The primary key and scope_key are SHA-256 digests; every read and mutation checks tenant, actor and thread scope. No foreign keys to chat rows.
  • 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 public routes.
  • Outcomes. A terminal outcome is admitted as one assistant message, written with its delivery state in one transaction; the message carries agent_message.action_proposal_outcome. Queued messages carry the client's renderer capabilities in agent_queued_message.ui_capabilities, and tool calls point at their proposal through agent_tool_call.proposal_id.
  • Certified drivers. Admission (actionProposalAdmissionSupported) is advertised only for better-sqlite3, node-postgres, mysql2 and libSQL. On any other Drizzle driver the store reports false and independent mode fails at configuration; blocking approvals still work.

Rollout order for an existing database

Proposals written before discovery shipped have discovery_index_version = 0, and workers only discover version-1 rows. Mixed old and new writers are unsupported.

  1. Stop writers running the older package version.

  2. Apply the additive schema — ensureAgentSchema(db), or your drizzle-kit migration from the updated schema object: proposal execution_status, lease_expires_at, proposal_expires_at, discovery_index_version (default 0), delivery_status, delivery_lease_expires_at, replacement_group_key, outcome_id_key and their indexes; plus the message, tool-call and queued-message columns above.

  3. Run the bounded backfill until it returns 0:

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

    Each call (limit 1–1000) rewrites discovery metadata from the stored snapshot under the row's version fence; it never changes the payload or runs a tool. Workers never backfill implicitly.

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

Peer dependencies

drizzle-orm (>=0.40.0 <1.0.0) and @nestjs/common (^10 \|\| ^11 \|\| ^12), plus your driver (better-sqlite3, pg, mysql2, @libsql/client, …).

When to use it

Reach for this adapter when your app already owns a Drizzle connection (or wants one) and you'd rather hand the agent a db handle than let an ORM own the lifecycle. SQLite, Postgres and MySQL are supported; for independent approvals, use one of the certified drivers above.

  • Persistence — the AgentStore SPI, the bound tokens, and choosing an adapter
  • Action proposals — independent approvals on top of this store

On this page