import type { Database } from "bun:sqlite"; import { getLogger } from "../../../util/logger.js"; import { getDbPath } from "../../../util/platform.js"; import { parseChangesFromStdout, runAsyncSqlite, } from "../../db-async-query.js"; import { type DrizzleDb, getMemorySqlite, getSqliteFrom, } from "../../db-connection.js"; const log = getLogger("memory-db"); /** * Incremental relocation of a heavy table out of the main DB and into its * own file (`assistant-logs.db` / `assistant-memory.db`). * * A one-shot `INSERT … SELECT` + `DROP TABLE` of a multi-GB table on the daemon * connection would pin the write lock and block the event loop for minutes. * Instead a relocation runs as an async migration step in two parts: * * 1. {@link stageTableForRelocation} renames the source table in `main` aside * to `__relocating` (instant, metadata-only), so live reads/writes * route to the dedicated connection's copy immediately. * 2. {@link drainStagedTable} copies the staged rows into the target file in * bounded batches and truncates them from the staging table as it goes, * then drops it. Each batch runs through `runAsyncSqlite`, which opens the * target file directly (the sqlite3 subprocess ATTACHes it), so the work * is independent of the daemon connection; the migration `await`s it to * completion before checkpointing, so later startup work observes the * finished move. * * The engine is generic: each migration owns its {@link RelocationSpec} (the * instance-specific columns / filter / target file) and passes it in. */ /** Suffix of the staging table the source is renamed to during relocation. */ export const RELOCATING_SUFFIX = "__relocating"; /** * Describes how to drain one relocatable table. Defined by the owning migration * — never centrally — so instance-specific SQL stays next to the migration that * needs it. */ export interface RelocationSpec { /** Live, unqualified table name (renamed aside, then drained into the target). */ table: string; /** Absolute path of the attached DB file the table is moved into. */ targetDbPath: () => string; /** * Columns to copy, in a fixed order — listed explicitly (not `SELECT *`) so * the copy is insensitive to the physical column order of the source table, * which varies with the history of `ALTER TABLE … ADD COLUMN` migrations. * Columns absent from the (legacy) source are copied as NULL. */ columns: string[]; /** * Optional predicate (evaluated against the staging table) selecting rows * worth preserving. Rows that do **not** match are deleted without being * copied. Omit to copy every row. */ copyWhere?: string; /** * Optional per-column SELECT expressions, keyed by column name, for columns * that must be transformed during the copy rather than carried verbatim * (e.g. resetting a status). The expression is evaluated against the staging * row; unlisted columns copy as-is (or NULL when absent from the source). */ columnExpr?: Record; } function tableExistsInMain(raw: Database, name: string): boolean { return ( raw .query( `SELECT name FROM main.sqlite_master WHERE type='table' AND name = ?`, ) .get(name) != null ); } function tableIsEmpty(raw: Database, name: string): boolean { // EXISTS short-circuits at the first row, so this stays cheap even on a huge // table — no full COUNT(*) scan. return raw.query(`SELECT 1 FROM main."${name}" LIMIT 1`).get() == null; } /** * Move the source table in `main` aside so the unqualified name resolves to the * attached copy, returning whether a staging table now exists (i.e. a drain is * needed). Idempotent and safe to re-run after a crash: * * - `main.
` empty → drop it (a freshly recreated shadow, or * an already-drained leftover). * - `main.
` non-empty, no * staging yet → rename it to `
__relocating`. * - staging already exists → leave it; a prior boot started the move. * * `table` comes from a {@link RelocationSpec} (never user input); it is quoted * defensively all the same. */ export function stageTableForRelocation(raw: Database, table: string): boolean { const staging = `${table}${RELOCATING_SUFFIX}`; const hasStaging = tableExistsInMain(raw, staging); if (tableExistsInMain(raw, table)) { if (tableIsEmpty(raw, table)) { raw.exec(`DROP TABLE main."${table}"`); } else if (!hasStaging) { raw.exec(`ALTER TABLE main."${table}" RENAME TO "${staging}"`); return true; } // else: a non-empty live table alongside an existing staging table is not // expected; leave both and let the in-flight drain finish first. } return tableExistsInMain(raw, staging); } /** * Rows copied/purged per drain batch. Each batch is a couple of bounded * statements, so the write lock is held only briefly and the event loop is free * between batches. Sized as a balance between throughput and lock-hold time. */ const DRAIN_BATCH = 10_000; /** * Drain a `
__relocating` staging table created by * {@link stageTableForRelocation}: copy the rows worth keeping into the attached * target in bounded batches, purge the rest without copying, then drop the * staging table and truncate the main WAL. Resolves once the move is complete. * * Awaited inline by the relocation migration step, so it runs at most once per * boot under the migration runner's checkpoint: an interrupted drain leaves the * step uncheckpointed and the staging table in place, and the next boot * re-stages (a no-op) and resumes from the remaining rows. Each batch is * dispatched off the connection via `runAsyncSqlite` (sqlite3 subprocess when * available; in-process fallback otherwise), keeping the event loop responsive * between batches. A batch failure throws so the step is reported failed and * retried on the next boot rather than checkpointed as done. */ export async function drainStagedTable( raw: Database, spec: RelocationSpec, ): Promise { const { table } = spec; const staging = `${table}${RELOCATING_SUFFIX}`; // Nothing to do once the staging table is gone (drain finished previously). if (!tableExistsInMain(raw, staging)) { return; } // Build a select list from the staging table's actual columns: apply any // per-column transform, copy a present column verbatim, NULL-fill an absent // (legacy) one so an older row still copies cleanly. const present = new Set( ( raw .query(`SELECT name FROM pragma_table_info('${staging}', 'main')`) .all() as Array<{ name: string }> ).map((r) => r.name), ); const colList = spec.columns.map((c) => `"${c}"`).join(", "); const selectList = spec.columns .map((c) => spec.columnExpr?.[c] ?? (present.has(c) ? `"${c}"` : "NULL")) .join(", "); const dbPath = getDbPath(); const whereCopy = spec.copyWhere ? `WHERE ${spec.copyWhere}` : ""; for (;;) { // (1) Purge a batch of non-keeper rows (no copy) when a copy filter narrows // what is worth preserving — this is the bulk of a runaway queue. let purged = 0; if (spec.copyWhere) { const res = await runAsyncSqlite( `DELETE FROM "${staging}" WHERE rowid IN (` + `SELECT rowid FROM "${staging}" WHERE NOT (${spec.copyWhere}) LIMIT ${DRAIN_BATCH});\n` + `SELECT changes();`, `relocation:purge-batch:${table}`, { dbPath }, ); if (!res.ok) { throw new Error( `relocation purge batch failed for "${table}": ${res.error}`, ); } purged = parseChangesFromStdout(res.stdout); } // (2) Copy a batch of keepers, then truncate those same rows. Two // autocommitted statements (no BEGIN): the copy commits before the // delete, so a crash in between just re-copies (INSERT OR IGNORE no-op) // and re-deletes next boot — safe across the non-atomic cross-DB commit. const copyRes = await runAsyncSqlite( `INSERT OR IGNORE INTO "${table}" (${colList}) ` + `SELECT ${selectList} FROM "${staging}" ${whereCopy} ORDER BY rowid LIMIT ${DRAIN_BATCH};\n` + `DELETE FROM "${staging}" WHERE rowid IN (` + `SELECT rowid FROM "${staging}" ${whereCopy} ORDER BY rowid LIMIT ${DRAIN_BATCH});\n` + `SELECT changes();`, `relocation:copy-batch:${table}`, { dbPath, attach: [{ path: spec.targetDbPath(), alias: "_reloc_target" }], }, ); if (!copyRes.ok) { throw new Error( `relocation copy batch failed for "${table}": ${copyRes.error}`, ); } const moved = parseChangesFromStdout(copyRes.stdout); if (purged > 0 || moved > 0) { log.info({ table, purged, moved }, "relocation: drain progressed"); continue; } break; } // Drained — drop the (now empty) staging table and truncate the main WAL. const finalizeRes = await runAsyncSqlite( `DROP TABLE IF EXISTS "${staging}";\nPRAGMA wal_checkpoint(TRUNCATE);`, `relocation:finalize:${table}`, { dbPath }, ); if (!finalizeRes.ok) { throw new Error( `relocation finalize failed for "${table}": ${finalizeRes.error}`, ); } log.info({ table }, "relocation: complete — staging dropped"); } /** * Run one memory-table relocation end to end: ensure the table's schema on the * memory connection, stage `main.
` aside via * {@link stageTableForRelocation}, then drain the staged rows into the memory * DB in awaited batches via {@link drainStagedTable} per the spec. Because the * drain is awaited as part of the migration step, later startup work observes * the finished move. * * Throws (rather than returning) if the memory database cannot be opened, so * the runner records the step as failed instead of applied and retries it on a * later boot — never renaming the source aside without a target to write to, * and never marking the relocation done while it has not happened. The throw * is caught per-step by the runner, so startup is not aborted. */ export async function runMemoryTableRelocation( database: DrizzleDb, spec: RelocationSpec, ensureSchema: (memoryRaw: Database) => void, ): Promise { const memoryRaw = getMemorySqlite(); if (!memoryRaw) { throw new Error( `memory database unavailable — deferring ${spec.table} relocation`, ); } ensureSchema(memoryRaw); const raw = getSqliteFrom(database); const needsDrain = stageTableForRelocation(raw, spec.table); if (needsDrain) { await drainStagedTable(raw, spec); } }