UNPKG

@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
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