@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
719 lines • 36.3 kB
JavaScript
import { isMySql, portableSql } from './sql-dialect.js';
/**
* The twelve agent table names. They match the cross-adapter snake_case contract the reference Drizzle
* store uses, so a dashboard or migration can point at any adapter and see the same physical schema.
*/
export const AGENT_TABLES = {
/** Independent action proposals, including durable execution work and its lease. */
actionProposals: 'agent_action_proposal',
threads: 'agent_thread',
messages: 'agent_message',
toolCalls: 'agent_tool_call',
tokenUsage: 'agent_token_usage',
modelPricing: 'agent_model_pricing',
runs: 'agent_run',
/** Messages sent while a turn was running, waiting to run after it (the chat queue). */
queuedMessages: 'agent_queued_message',
/** Confirm tokens already spent, by hash (`LucidConfirmTokenStore`, single-use confirmed writes). */
confirmTokens: 'agent_confirm_token',
/** A run's live stream, buffered for replay by the Lucid token sink (`tokenSinks.lucid()`). */
streamFrames: 'agent_stream_frame',
/**
* Text channels' short-lived state (`lucidChannelStore`): the provider message ids already taken,
* questions waiting for an answer, outcomes already relayed — each row expiring at `expires_at`.
*/
channelState: 'agent_channel_state',
/**
* Generative-UI telemetry: one row per UI tool call (`recordGenuiCalls`) — metadata only, the
* call's input only when its tool samples payloads. Read by the dashboard's Generative UI page.
*/
genuiCalls: 'agent_genui_call',
};
/**
* One statement of this module, as `db`'s dialect has to read it. The statements are written once,
* Postgres/SQLite-style; MySQL needs four things changed, and every one of them used to fail or
* silently misbehave there:
*
* - identifiers in backticks — a `"quoted"` name is a string literal to MySQL, so every statement
* failed to parse;
* - `TEXT` as `LONGTEXT` — MySQL's `TEXT` holds 64 KB, and a long answer, a tool's output or a stream
* frame past that failed the insert;
* - no `IF NOT EXISTS` on `CREATE INDEX`, which MySQL does not have (the catalog is read first, see
* {@link existingIndexes});
* - InnoDB with a case-sensitive collation: the foreign keys need InnoDB, and under MySQL's default
* `utf8mb4_0900_ai_ci` an actor `alice` read the threads of an actor `ALICE`. Postgres and SQLite
* compare those exactly; `utf8mb4_bin` makes MySQL do the same.
*/
export function forDialect(statement, mysql) {
if (!mysql)
return statement;
let out = statement.replaceAll('"', '`').replace(/\bTEXT\b/g, 'LONGTEXT');
out = out.replace(/^CREATE INDEX IF NOT EXISTS /, 'CREATE INDEX ');
if (/^CREATE TABLE /.test(out)) {
out = `${out} ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`;
}
return out;
}
/**
* `CREATE TABLE IF NOT EXISTS` for the Lucid token sink's frame buffer, under `table` (the sink takes
* a `tableName`). One row per frame of a run's stream, numbered from 1 in write order; the row whose
* `frame` is `NULL` is the end marker. `created_at` is what the sink's TTL purge reads. No foreign
* key: a sink is usable with any store, and a delegated run writes under its ANCESTOR's run id.
*/
export function streamFrameTableStatement(table = AGENT_TABLES.streamFrames) {
return `CREATE TABLE IF NOT EXISTS "${table}" (
"run_id" VARCHAR(255) NOT NULL,
"seq" BIGINT NOT NULL,
"frame" TEXT NULL,
"created_at" BIGINT NOT NULL,
PRIMARY KEY ("run_id", "seq")
)`;
}
/**
* `CREATE TABLE IF NOT EXISTS` for text channels' state (`lucidChannelStore`), under `table`. The
* primary key IS the dedupe lock: two deliveries of one message race on the insert and one loses.
* No foreign key: the rows name provider ids, not agent rows.
*/
export function channelStateTableStatements(table = AGENT_TABLES.channelState) {
return [
`CREATE TABLE IF NOT EXISTS "${table}" (
"key" VARCHAR(255) PRIMARY KEY NOT NULL,
"value" TEXT NULL,
"expires_at" BIGINT NOT NULL,
"created_at" BIGINT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS "${table}_expires_idx" ON "${table}" ("expires_at")`,
];
}
/**
* `CREATE TABLE IF NOT EXISTS` DDL for the twelve agent tables plus their indexes, one statement per
* array element so each can be issued through Lucid's `rawQuery`. Portable across SQLite / Postgres /
* MySQL: quoted identifiers, epoch-ms `BIGINT` timestamps, `INTEGER` booleans (0/1) and `TEXT` JSON
* columns — no dialect-only types. A real deployment should prefer the bundled migration stub so the
* schema is versioned; this helper lets a store stand itself up in tests and scripts.
*
* A tool call is addressed by the model-supplied `toolCallId`, kept as-is in `call_id`. The row's PK
* is that id scoped to its assistant message (see `toolCallRowId` in `lucid.ts`): a provider only
* promises the id is unique within one response, and some (short `call_<digits>` ids behind
* OpenRouter) repeat one across runs. A row from before `call_id` existed has it NULL and its PK is
* the bare model id.
*
* The `agent_run` table records each run (turn) lifecycle; `agent_message` / `agent_tool_call` /
* `agent_token_usage` each carry a nullable `run_id` correlation column (logically referencing
* `agent_run.id`, deliberately WITHOUT a DB-level foreign key — like the reference — so the additive
* migration can `ALTER TABLE ADD COLUMN` portably and a row recorded before run tracking shipped can
* keep a `null` run_id).
*/
export function createTableStatements() {
const t = AGENT_TABLES;
return [
`CREATE TABLE IF NOT EXISTS "${t.actionProposals}" (
"id" VARCHAR(64) PRIMARY KEY NOT NULL,
"scope_key" VARCHAR(64) NOT NULL,
"logical_sort" VARCHAR(1020) NOT NULL,
"insert_token" VARCHAR(64) NOT NULL,
"decision" VARCHAR(32) NOT NULL,
"execution_status" VARCHAR(32) NULL,
"lease_expires_at" BIGINT NULL,
"proposal_expires_at" BIGINT NULL,
"discovery_index_version" INTEGER NOT NULL DEFAULT 0,
"replacement_group_key" VARCHAR(64) NULL,
"outcome_key" VARCHAR(64) NULL,
"delivery_status" VARCHAR(32) NULL,
"delivery_lease_expires_at" BIGINT NULL,
"payload" TEXT NOT NULL,
"version" BIGINT NOT NULL DEFAULT 0,
"created_at" BIGINT NOT NULL,
"updated_at" BIGINT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS "${t.actionProposals}_work_idx" ON "${t.actionProposals}" ("discovery_index_version", "decision", "execution_status", "lease_expires_at", "created_at")`,
`CREATE INDEX IF NOT EXISTS "${t.actionProposals}_expiry_idx" ON "${t.actionProposals}" ("discovery_index_version", "decision", "proposal_expires_at", "created_at")`,
`CREATE INDEX IF NOT EXISTS "${t.actionProposals}_backfill_idx" ON "${t.actionProposals}" ("discovery_index_version", "created_at")`,
`CREATE INDEX IF NOT EXISTS "${t.actionProposals}_replacement_idx" ON "${t.actionProposals}" ("scope_key", "replacement_group_key", "decision")`,
`CREATE INDEX IF NOT EXISTS "${t.actionProposals}_delivery_idx" ON "${t.actionProposals}" ("delivery_status", "delivery_lease_expires_at", "created_at")`,
`CREATE INDEX IF NOT EXISTS "${t.actionProposals}_outcome_idx" ON "${t.actionProposals}" ("outcome_key")`,
`CREATE INDEX IF NOT EXISTS "${t.actionProposals}_scope_idx" ON "${t.actionProposals}" ("scope_key", "created_at")`,
`CREATE TABLE IF NOT EXISTS "${t.threads}" (
"id" VARCHAR(255) PRIMARY KEY NOT NULL,
"actor_ref" VARCHAR(255) NOT NULL,
"tenant_ref" VARCHAR(255) NULL,
"title" TEXT NOT NULL,
"persona" VARCHAR(255) NOT NULL DEFAULT 'default',
"transient" INTEGER NOT NULL DEFAULT 0,
"pinned_at" BIGINT NULL,
"summary" TEXT NULL,
"summary_message_count" INTEGER NOT NULL DEFAULT 0,
"active_stream_id" VARCHAR(255) NULL,
"model" VARCHAR(255) NULL,
"queue_pause" TEXT NULL,
"default_agent" VARCHAR(255) NULL,
"created_at" BIGINT NOT NULL,
"updated_at" BIGINT NOT NULL,
"deleted_at" BIGINT NULL
)`,
`CREATE INDEX IF NOT EXISTS "${t.threads}_actor_updated_idx" ON "${t.threads}" ("actor_ref", "updated_at")`,
`CREATE TABLE IF NOT EXISTS "${t.messages}" (
"id" VARCHAR(255) PRIMARY KEY NOT NULL,
"thread_id" VARCHAR(255) NOT NULL,
"role" VARCHAR(255) NOT NULL,
"content" TEXT NOT NULL,
"tool_calls" TEXT NULL,
"tool_results" TEXT NULL,
"attachments" TEXT NULL,
"follow_ups" TEXT NULL,
"usage" TEXT NULL,
"persona" VARCHAR(255) NULL,
"run_id" VARCHAR(255) NULL,
"reasoning" TEXT NULL,
"reasoning_ms" INTEGER NULL,
"ui" TEXT NULL,
"action_proposal_outcome" TEXT NULL,
"feedback" TEXT NULL,
"agent_name" VARCHAR(255) NULL,
"seq" BIGINT NOT NULL DEFAULT 0,
"sent_at" BIGINT NULL,
"created_at" BIGINT NOT NULL,
FOREIGN KEY ("thread_id") REFERENCES "${t.threads}" ("id") ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS "${t.messages}_thread_created_idx" ON "${t.messages}" ("thread_id", "created_at")`,
`CREATE INDEX IF NOT EXISTS "${t.messages}_run_idx" ON "${t.messages}" ("run_id")`,
`CREATE TABLE IF NOT EXISTS "${t.toolCalls}" (
"id" VARCHAR(255) PRIMARY KEY NOT NULL,
"message_id" VARCHAR(255) NOT NULL,
"tool_name" VARCHAR(255) NOT NULL,
"tool_type" VARCHAR(255) NOT NULL,
"proposal_id" VARCHAR(255) NULL,
"input" TEXT NULL,
"output" TEXT NULL,
"status" VARCHAR(255) NOT NULL,
"executed_by_ref" VARCHAR(255) NULL,
"execution_ms" INTEGER NULL,
"error" TEXT NULL,
"run_id" VARCHAR(255) NULL,
"created_at" BIGINT NOT NULL,
"executed_at" BIGINT NULL,
"approver" VARCHAR(255) NULL,
"confirmation" TEXT NULL,
"expires_at" BIGINT NULL,
"remember" INTEGER NULL,
"decided_via" VARCHAR(64) NULL,
"call_id" VARCHAR(255) NULL,
FOREIGN KEY ("message_id") REFERENCES "${t.messages}" ("id") ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS "${t.toolCalls}_run_idx" ON "${t.toolCalls}" ("run_id")`,
`CREATE INDEX IF NOT EXISTS "${t.toolCalls}_call_idx" ON "${t.toolCalls}" ("call_id", "run_id")`,
`CREATE INDEX IF NOT EXISTS "${t.toolCalls}_status_created_idx" ON "${t.toolCalls}" ("status", "created_at")`,
`CREATE TABLE IF NOT EXISTS "${t.tokenUsage}" (
"id" VARCHAR(255) PRIMARY KEY NOT NULL,
"thread_id" VARCHAR(255) NOT NULL,
"actor_ref" VARCHAR(255) NOT NULL,
"message_id" VARCHAR(255) NULL,
"model_id" VARCHAR(255) NOT NULL,
"purpose" VARCHAR(255) NOT NULL,
"input_tokens" INTEGER NOT NULL,
"output_tokens" INTEGER NOT NULL,
"cache_write_tokens" INTEGER NULL,
"cache_read_tokens" INTEGER NULL,
"cost_usd" DOUBLE PRECISION NULL,
"cost_source" VARCHAR(16) NULL,
"run_id" VARCHAR(255) NULL,
"created_at" BIGINT NOT NULL,
FOREIGN KEY ("thread_id") REFERENCES "${t.threads}" ("id") ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS "${t.tokenUsage}_actor_created_idx" ON "${t.tokenUsage}" ("actor_ref", "created_at")`,
`CREATE INDEX IF NOT EXISTS "${t.tokenUsage}_run_idx" ON "${t.tokenUsage}" ("run_id")`,
`CREATE TABLE IF NOT EXISTS "${t.modelPricing}" (
"id" VARCHAR(255) PRIMARY KEY NOT NULL,
"model_id" VARCHAR(255) NOT NULL,
"input_price_per_1m" DOUBLE PRECISION NOT NULL,
"output_price_per_1m" DOUBLE PRECISION NOT NULL,
"cache_write_price_per_1m" DOUBLE PRECISION NULL,
"cache_read_price_per_1m" DOUBLE PRECISION NULL,
"effective_from" BIGINT NOT NULL,
"is_current" INTEGER NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS "${t.runs}" (
"id" VARCHAR(255) PRIMARY KEY NOT NULL,
"thread_id" VARCHAR(255) NOT NULL,
"agent_name" VARCHAR(255) NULL,
"parent_run_id" VARCHAR(255) NULL,
"actor_ref" VARCHAR(255) NOT NULL,
"tenant_ref" VARCHAR(255) NULL,
"status" VARCHAR(255) NOT NULL,
"started_at" BIGINT NOT NULL,
"finished_at" BIGINT NULL,
"step_count" INTEGER NOT NULL DEFAULT 0,
"input_tokens" INTEGER NOT NULL DEFAULT 0,
"output_tokens" INTEGER NOT NULL DEFAULT 0,
"cost_usd" DOUBLE PRECISION NULL,
"error" TEXT NULL,
"durable" INTEGER NOT NULL DEFAULT 0,
"ttft_ms" INTEGER NULL,
"error_code" VARCHAR(64) NULL,
"model_id" VARCHAR(255) NULL,
FOREIGN KEY ("thread_id") REFERENCES "${t.threads}" ("id") ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS "${t.runs}_started_idx" ON "${t.runs}" ("started_at")`,
`CREATE INDEX IF NOT EXISTS "${t.runs}_actor_started_idx" ON "${t.runs}" ("actor_ref", "started_at")`,
`CREATE INDEX IF NOT EXISTS "${t.runs}_status_started_idx" ON "${t.runs}" ("status", "started_at")`,
`CREATE TABLE IF NOT EXISTS "${t.queuedMessages}" (
"id" VARCHAR(255) PRIMARY KEY NOT NULL,
"thread_id" VARCHAR(255) NOT NULL,
"actor" TEXT NOT NULL,
"content" TEXT NOT NULL,
"attachments" TEXT NULL,
"agent_name" VARCHAR(255) NULL,
"persona" VARCHAR(255) NULL,
"model" VARCHAR(255) NULL,
"page_context" TEXT NULL,
"ui_capabilities" TEXT NULL,
"host_context" TEXT NULL,
"sent_at" BIGINT NULL,
"interrupt" INTEGER NOT NULL DEFAULT 0,
"position" INTEGER NOT NULL,
"created_at" BIGINT NOT NULL,
"updated_at" BIGINT NOT NULL,
FOREIGN KEY ("thread_id") REFERENCES "${t.threads}" ("id") ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS "${t.queuedMessages}_thread_position_idx" ON "${t.queuedMessages}" ("thread_id", "position")`,
// The primary key IS the lock: two confirmations of one token race on the insert and one loses.
`CREATE TABLE IF NOT EXISTS "${t.confirmTokens}" (
"hash" VARCHAR(64) PRIMARY KEY NOT NULL,
"actor_ref" VARCHAR(255) NOT NULL,
"tool" VARCHAR(255) NOT NULL,
"expires_at" BIGINT NOT NULL,
"created_at" BIGINT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS "${t.confirmTokens}_expires_idx" ON "${t.confirmTokens}" ("expires_at")`,
streamFrameTableStatement(t.streamFrames),
...channelStateTableStatements(t.channelState),
`CREATE TABLE IF NOT EXISTS "${t.genuiCalls}" (
"id" VARCHAR(255) PRIMARY KEY NOT NULL,
"run_id" VARCHAR(255) NOT NULL,
"thread_id" VARCHAR(255) NOT NULL,
"actor_ref" VARCHAR(255) NOT NULL,
"agent_name" VARCHAR(255) NULL,
"step" INTEGER NOT NULL,
"tool_name" VARCHAR(255) NOT NULL,
"channel" VARCHAR(64) NOT NULL,
"mode" VARCHAR(32) NOT NULL,
"model_id" VARCHAR(255) NOT NULL,
"attempt" INTEGER NOT NULL,
"outcome" VARCHAR(16) NOT NULL,
"validity" VARCHAR(16) NOT NULL,
"error_category" VARCHAR(32) NULL,
"error_message" TEXT NULL,
"input_tokens" INTEGER NOT NULL,
"output_tokens" INTEGER NOT NULL,
"cost_usd" DOUBLE PRECISION NULL,
"cost_source" VARCHAR(16) NULL,
"first_frame_ms" BIGINT NULL,
"complete_frame_ms" BIGINT NULL,
"payload_bytes" INTEGER NOT NULL,
"payload" TEXT NULL,
"created_at" BIGINT NOT NULL,
FOREIGN KEY ("thread_id") REFERENCES "${t.threads}" ("id") ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS "${t.genuiCalls}_created_idx" ON "${t.genuiCalls}" ("created_at")`,
`CREATE INDEX IF NOT EXISTS "${t.genuiCalls}_run_idx" ON "${t.genuiCalls}" ("run_id")`,
];
}
/**
* The `run_id` correlation columns run tracking added to three pre-existing tables. They are inline in
* {@link createTableStatements}, which is all a fresh database needs — but `CREATE TABLE IF NOT EXISTS`
* cannot add a column to a table that already exists, so a database provisioned before run tracking
* shipped needs them ALTERed in. Deliberately not DB foreign keys (SQLite cannot add a column WITH a
* FK constraint), so a row written before run tracking keeps a `null` run_id.
*/
const RUN_ID_COLUMNS = [
AGENT_TABLES.messages,
AGENT_TABLES.toolCalls,
AGENT_TABLES.tokenUsage,
];
/**
* Columns added to a table that already exists in a deployed database, so `CREATE TABLE IF NOT
* EXISTS` can never reach them. Repaired the same way the `run_id` columns are, and for the same
* reason: the store writes them on every turn.
*/
const ADDITIVE_COLUMNS = [
{ table: AGENT_TABLES.queuedMessages, column: 'ui_capabilities', type: 'TEXT NULL' },
{ table: AGENT_TABLES.queuedMessages, column: 'host_context', type: 'TEXT NULL' },
{ table: AGENT_TABLES.toolCalls, column: 'proposal_id', type: 'VARCHAR(255) NULL' },
{
table: AGENT_TABLES.actionProposals,
column: 'replacement_group_key',
type: 'VARCHAR(64) NULL',
},
{ table: AGENT_TABLES.actionProposals, column: 'outcome_key', type: 'VARCHAR(64) NULL' },
{ table: AGENT_TABLES.actionProposals, column: 'delivery_status', type: 'VARCHAR(32) NULL' },
{ table: AGENT_TABLES.actionProposals, column: 'delivery_lease_expires_at', type: 'BIGINT NULL' },
{ table: AGENT_TABLES.messages, column: 'action_proposal_outcome', type: 'TEXT NULL' },
{ table: AGENT_TABLES.actionProposals, column: 'execution_status', type: 'VARCHAR(32) NULL' },
{ table: AGENT_TABLES.actionProposals, column: 'lease_expires_at', type: 'BIGINT NULL' },
{ table: AGENT_TABLES.actionProposals, column: 'proposal_expires_at', type: 'BIGINT NULL' },
{
table: AGENT_TABLES.actionProposals,
column: 'discovery_index_version',
type: 'INTEGER NOT NULL DEFAULT 0',
},
{ table: AGENT_TABLES.runs, column: 'parent_run_id', type: 'VARCHAR(255) NULL' },
{ table: AGENT_TABLES.messages, column: 'reasoning', type: 'TEXT NULL' },
{ table: AGENT_TABLES.messages, column: 'reasoning_ms', type: 'INTEGER NULL' },
{ table: AGENT_TABLES.messages, column: 'ui', type: 'TEXT NULL' },
{ table: AGENT_TABLES.messages, column: 'feedback', type: 'TEXT NULL' },
{ table: AGENT_TABLES.messages, column: 'agent_name', type: 'VARCHAR(255) NULL' },
// A message's place in its thread, assigned on append. 0 on a row from before: those sort first.
{ table: AGENT_TABLES.messages, column: 'seq', type: 'BIGINT NOT NULL DEFAULT 0' },
{ table: AGENT_TABLES.threads, column: 'model', type: 'VARCHAR(255) NULL' },
{ table: AGENT_TABLES.threads, column: 'queue_pause', type: 'TEXT NULL' },
{ table: AGENT_TABLES.threads, column: 'default_agent', type: 'VARCHAR(255) NULL' },
// The persona a queued message's send resolved, so it starts under that one.
{ table: AGENT_TABLES.queuedMessages, column: 'persona', type: 'VARCHAR(255) NULL' },
{ table: AGENT_TABLES.toolCalls, column: 'confirmation', type: 'TEXT NULL' },
{ table: AGENT_TABLES.toolCalls, column: 'approver', type: 'VARCHAR(255) NULL' },
{ table: AGENT_TABLES.toolCalls, column: 'expires_at', type: 'BIGINT NULL' },
{ table: AGENT_TABLES.toolCalls, column: 'remember', type: 'INTEGER NULL' },
{ table: AGENT_TABLES.toolCalls, column: 'decided_via', type: 'VARCHAR(64) NULL' },
// The model's own tool call id, now that the PK is scoped to the run. NULL on a row from before:
// its PK is that id.
{ table: AGENT_TABLES.toolCalls, column: 'call_id', type: 'VARCHAR(255) NULL' },
// Where a usage row's `cost_usd` came from: 'provider' | 'estimate'. NULL on rows from before it
// existed, which only ever held a provider-reported cost.
{ table: AGENT_TABLES.tokenUsage, column: 'cost_source', type: 'VARCHAR(16) NULL' },
// A run's time to first token, its failure's stream error code and its last model. NULL on a run
// from before they were recorded, and on the runs they don't apply to.
{ table: AGENT_TABLES.runs, column: 'ttft_ms', type: 'INTEGER NULL' },
{ table: AGENT_TABLES.runs, column: 'error_code', type: 'VARCHAR(64) NULL' },
{ table: AGENT_TABLES.runs, column: 'model_id', type: 'VARCHAR(255) NULL' },
// When the person sent a message / a queued send (epoch ms), when not when it was stored. NULL on
// a row from before, and on every message nobody named a send time for: `created_at` stands in.
{ table: AGENT_TABLES.messages, column: 'sent_at', type: 'BIGINT NULL' },
{ table: AGENT_TABLES.queuedMessages, column: 'sent_at', type: 'BIGINT NULL' },
];
/**
* Does `table` already have `column`? Probed with a zero-row `SELECT` rather than `information_schema`
* (absent on SQLite) or a `PRAGMA` (SQLite-only): every dialect rejects an unknown column at parse
* time, and `WHERE 1 = 0` means no rows are ever read.
*
* Deliberately NOT "attempt the ALTER and swallow the error": that pattern also swallows a permission
* failure, a lock timeout, and a typo, leaving a schema that is still wrong and a migration that
* reported success.
*/
async function hasColumn(db, table, column) {
try {
await db.rawQuery(portableSql(db, `SELECT "${column}" FROM "${table}" WHERE 1 = 0`));
return true;
}
catch {
return false;
}
}
/**
* Whether `agent_tool_call` has its `call_id` column (#183). What `LucidAgentStore` checks when the
* app owns the schema (`autoCreateTables: false`) and so may boot a new version before its migration
* has added the column. Probed on {@link schemaRunner}'s client, outside any global transaction.
*/
export function hasToolCallIdColumn(db) {
return hasColumn(schemaRunner(db), AGENT_TABLES.toolCalls, 'call_id');
}
/**
* Whether `agent_message` and `agent_queued_message` have their `sent_at` columns — what
* `LucidAgentStore` checks under `autoCreateTables: false`, where the app may boot this version before
* its migration has added them. Probed like {@link hasToolCallIdColumn}.
*/
export async function hasSentAtColumns(db) {
const runner = schemaRunner(db);
return ((await hasColumn(runner, AGENT_TABLES.messages, 'sent_at')) &&
(await hasColumn(runner, AGENT_TABLES.queuedMessages, 'sent_at')));
}
/** Rows of a raw query result, whatever the driver wraps them in (`{ rows }`, `[rows, fields]`, rows). */
export function rowsOf(result) {
if (Array.isArray(result)) {
return Array.isArray(result[0])
? result[0]
: result;
}
const rows = result?.rows;
return Array.isArray(rows) ? rows : [];
}
/**
* The names of the indexes this database already has, or `null` when the dialect offers no catalog
* this knows how to read (then every `CREATE INDEX IF NOT EXISTS` is simply issued).
*
* This is what keeps an up-to-date schema LOCK-FREE. Postgres takes the table lock for
* `CREATE INDEX IF NOT EXISTS` before it looks whether the index is there, and that lock conflicts
* with any open transaction that wrote to the table — so the "no-op" statement waits for it. A test
* suite that wraps everything in one global transaction never commits, and the wait never ends. A
* catalog read takes no such lock, so an index that exists is never touched.
*
* Postgres first: it is the one dialect where a failed statement poisons an ambient transaction, and
* there the first probe succeeds.
*/
async function existingIndexes(db) {
// MySQL first there: it has no `pg_indexes`, and its own catalog is the only probe that answers.
const mysqlProbe = 'SELECT index_name AS name FROM information_schema.statistics WHERE table_schema = DATABASE()';
if (isMySql(db)) {
const rows = rowsOf(await db.rawQuery(mysqlProbe));
return new Set(rows.map((row) => String(row.name ?? row.NAME)));
}
const probes = [
// Every schema on the search path, not only the first: an unqualified `CREATE INDEX … ON "t"`
// finds `t` wherever the path does, so that is where its index has to be looked for.
'SELECT indexname AS name FROM pg_indexes WHERE schemaname = ANY (current_schemas(false))',
`SELECT name FROM sqlite_master WHERE type = 'index'`,
];
for (const probe of probes) {
try {
const rows = rowsOf(await db.rawQuery(probe));
return new Set(rows.map((row) => String(row.name)));
}
catch {
// Not this dialect — try the next catalog.
}
}
return null;
}
/** Does `table` exist? Same zero-row probe as {@link hasColumn}. */
async function hasTable(db, table) {
try {
await db.rawQuery(portableSql(db, `SELECT 1 FROM "${table}" WHERE 1 = 0`));
return true;
}
catch {
return false;
}
}
/**
* Run one DDL statement, tolerating exactly one failure: another process got there first. Several
* processes boot at once (web + worker, or a rolling deploy) and each provisions the schema; `IF NOT
* EXISTS` is not atomic across sessions (two concurrent `CREATE TABLE IF NOT EXISTS` on Postgres can
* both pass the check and one then fails on the catalog's unique index), and `ADD COLUMN` has no
* guard at all. So a failure is re-checked against the database: if what the statement was meant to
* create is there now, the race was lost and the job is done. Anything else — a permission error, a
* lock timeout, a typo — still throws.
*/
async function issue(db, statement, done) {
for (let attempt = 1;; attempt += 1) {
try {
await db.rawQuery(statement);
return;
}
catch (error) {
if (await done())
return;
// MySQL settles two sessions building the same index at once by rolling one back as a deadlock
// victim — and the winner's index may not be in the catalog yet when the loser looks. That
// statement did nothing and is safe to send again; the next try either lands or finds it done.
if (attempt >= DDL_ATTEMPTS || !isTransientLockFailure(error))
throw error;
await new Promise((resolve) => setTimeout(resolve, 25 * attempt));
}
}
}
const DDL_ATTEMPTS = 5;
/** A statement the database rolled back to break a lock conflict: MySQL 1213/1205, Postgres 40P01/40001. */
function isTransientLockFailure(error) {
for (let current = error; current !== null && typeof current === 'object';) {
const { code, errno } = current;
if (code === 'ER_LOCK_DEADLOCK' ||
code === 'ER_LOCK_WAIT_TIMEOUT' ||
errno === 1213 ||
errno === 1205 ||
code === '40P01' ||
code === '40001') {
return true;
}
current = current.cause;
}
return false;
}
/**
* Idempotently provision the twelve agent tables through Lucid's async raw runner (`CREATE TABLE IF
* NOT EXISTS`), then additively repair a database that predates run tracking by ALTERing in the
* `run_id` columns its three older tables are missing.
*
* Both halves matter, and for different populations. A fresh database gets everything from the
* `CREATE TABLE` statements and never reaches a repair branch — which is what makes a non-empty
* `repairs` result mean "this schema was out of date", not "boot happened". A database created before
* run tracking shipped has all three tables already, so every `CREATE TABLE IF NOT EXISTS` no-ops and
* only the repair adds the columns the store now writes on every turn.
*
* Against a schema that is already current it issues no DDL that takes a table lock: an existing
* index is found in the catalog instead of re-issued (see {@link existingIndexes}). And it is safe
* with several processes provisioning at once (see {@link issue}).
*
* Returns the `<table>.<column>` repairs actually issued, so a caller can report them. Works on every
* Lucid dialect. For an AdonisJS app prefer the published migration
* (`node ace configure @adonis-agora/agent`); this helper is what that migration calls, and what the
* stores call themselves when `autoCreateTables` is on.
*/
export async function createAgentTables(db) {
const statements = createTableStatements();
const mysql = isMySql(db);
// Order matters, in three phases rather than one pass. Tables first, then the `run_id` repair, then
// indexes — because `createTableStatements` includes `CREATE INDEX ... ON "agent_message" ("run_id")`,
// and on a legacy database that index would be issued against a table whose `run_id` the repair has
// not added yet. `CREATE INDEX IF NOT EXISTS` does not save it: the guard is on the INDEX existing,
// not on the column, so the statement still fails to parse with "no such column: run_id".
for (const stmt of statements) {
if (!stmt.startsWith('CREATE TABLE'))
continue;
const table = /^CREATE TABLE IF NOT EXISTS "([^"]+)"/.exec(stmt)?.[1];
await issue(db, forDialect(stmt, mysql), () => hasTable(db, table));
}
const repairs = [];
const addColumn = async (table, column, type) => {
if (await hasColumn(db, table, column))
return;
await issue(db, forDialect(`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`, mysql), () => hasColumn(db, table, column));
repairs.push(`${table}.${column}`);
};
for (const table of RUN_ID_COLUMNS) {
await addColumn(table, 'run_id', 'VARCHAR(255) NULL');
}
for (const { table, column, type } of ADDITIVE_COLUMNS) {
await addColumn(table, column, type);
}
const indexes = await existingIndexes(db);
for (const stmt of statements) {
if (!stmt.startsWith('CREATE INDEX'))
continue;
const index = /^CREATE INDEX IF NOT EXISTS "([^"]+)"/.exec(stmt)?.[1];
if (indexes?.has(index))
continue;
await issue(db, forDialect(stmt, mysql), async () => (await existingIndexes(db))?.has(index) ?? false);
}
return repairs;
}
/**
* `DROP TABLE IF EXISTS` for the twelve agent tables, in reverse dependency order so a dialect that
* enforces the `REFERENCES` clauses never refuses a drop for a child that still exists. The mirror of
* {@link createAgentTables}, and what the published migration's `down()` calls.
*/
export function dropTableStatements() {
const t = AGENT_TABLES;
return [
t.genuiCalls,
t.actionProposals,
t.channelState,
t.confirmTokens,
t.streamFrames,
t.queuedMessages,
t.runs,
t.tokenUsage,
t.toolCalls,
t.messages,
t.modelPricing,
t.threads,
].map((table) => `DROP TABLE IF EXISTS "${table}"`);
}
/** Drop the twelve agent tables. Destructive and irreversible — this erases every thread and every ledger row. */
export async function dropAgentTables(db) {
for (const stmt of dropTableStatements()) {
await db.rawQuery(portableSql(db, stmt));
}
}
function isManager(db) {
const candidate = db;
return (candidate !== null &&
typeof candidate === 'object' &&
candidate.connectionGlobalTransactions instanceof Map &&
typeof candidate.connection === 'function' &&
typeof candidate.primaryConnectionName === 'string');
}
/**
* The client schema DDL should run on: one of its OWN, outside a global transaction the caller has
* open (`db.beginGlobalTransaction()` — what `testUtils.db().withGlobalTransaction()` does for a Japa
* suite). Inside that transaction the DDL would be rolled back with the test while the process still
* remembers the schema as provisioned, and on Postgres the column probes — which fail by design on a
* missing column — would abort the whole transaction.
*
* Lucid's manager answers every `connection()` with the global transaction while one is registered,
* so the registration is lifted for the one synchronous call that builds a plain client and put back
* before anything else can run.
*
* SQLite is left where it is: it has one writer, so a second connection could only wait for the very
* transaction it is trying to avoid (and with a pool of one there is no second connection at all).
* Its DDL is transactional and a failed probe does not abort anything, so running inside is correct.
*
* Anything that is not the `Database` manager — a client from `db.connection(name)`, a fake — is
* returned as is: a plain client is already outside, and a transaction client cannot be escaped.
*/
export function schemaRunner(db) {
if (!isManager(db))
return db;
const name = db.primaryConnectionName;
const transaction = db.connectionGlobalTransactions.get(name);
if (transaction === undefined)
return db;
if (/sqlite|libsql/i.test(transaction.dialect?.name ?? ''))
return db;
db.connectionGlobalTransactions.delete(name);
try {
return db.connection(name);
}
finally {
db.connectionGlobalTransactions.set(name, transaction);
}
}
/**
* Provisioning promise memoized per db client, so the three Lucid-backed stores (the agent store,
* the pricing store, the governance read-model) that share these tables run {@link createAgentTables}
* exactly once against a given connection instead of racing six `CREATE TABLE IF NOT EXISTS` each.
* Correctness never depends on the memo — the DDL is idempotent — it only avoids redundant round
* trips. A failed provisioning is evicted so the next call retries rather than caching the rejection.
*/
const provisioned = new WeakMap();
/**
* Idempotently ensure the twelve agent tables exist, memoized per db client. This is what the stores
* run when `autoCreateTables` is on (the default): the agent provider calls each store's
* `ensureSchema()` once as the app starts, and a store used without the provider (a script, a test
* that builds one by hand) falls back to it on first use — whichever store touches the connection
* first provisions the shared schema, so pricing seeds and governance reads work even before the
* first agent run. The DDL runs on {@link schemaRunner}'s client, never inside a global transaction.
*/
export function ensureAgentTables(db) {
const key = db;
let ready = provisioned.get(key);
if (ready === undefined) {
ready = createAgentTables(schemaRunner(db))
.then(() => undefined)
.catch((error) => {
provisioned.delete(key);
throw error;
});
provisioned.set(key, ready);
}
return ready;
}
/**
* Idempotently create the Lucid token sink's frame table, under whatever name the sink was given.
* What the sink runs for itself (`autoCreateTables`), so it stands up next to ANY store — the
* in-memory one included — without provisioning the other seven tables. {@link createAgentTables}
* creates the same table under its default name, so the published migration covers it too.
*/
export async function ensureStreamFrameTable(db, table = AGENT_TABLES.streamFrames) {
const runner = schemaRunner(db);
if (await hasTable(runner, table))
return;
await issue(runner, forDialect(streamFrameTableStatement(table), isMySql(runner)), () => hasTable(runner, table));
}
/**
* Idempotently create text channels' state table, under whatever name the store was given — what
* `lucidChannelStore` runs for itself (`autoCreateTables`), so it stands up next to any agent store.
* {@link createAgentTables} creates the same table under its default name, so the published
* migration covers it too.
*/
export async function ensureChannelStateTable(db, table = AGENT_TABLES.channelState) {
const runner = schemaRunner(db);
const mysql = isMySql(runner);
const [create, index] = channelStateTableStatements(table);
if (!(await hasTable(runner, table))) {
await issue(runner, forDialect(create, mysql), () => hasTable(runner, table));
}
const name = `${table}_expires_idx`;
if ((await existingIndexes(runner))?.has(name))
return;
await issue(runner, forDialect(index, mysql), async () => (await existingIndexes(runner))?.has(name) ?? false);
}
//# sourceMappingURL=lucid-schema.js.map