/** * FTS5 ↔ base-table rowid integrity. * * The full-text indexes (`facts_fts`, `episodes_fts`, `session_messages_fts`, * `insights_fts`) are keyed to their base tables by the base row's **implicit * rowid** - FTS rows are written with `rowid = (SELECT rowid FROM WHERE * id = ?)` and searches join `base.rowid = fts.rowid`. SQLite documents that * `VACUUM` may renumber implicit rowids, which would silently re-point every * FTS hit at a different record. * * Graphorin never issues `VACUUM`, and the encrypted-export path copies the * database file byte-for-byte (preserving rowids) before an in-place rekey, so * the hazard is latent - but a human running `VACUUM ` by hand would * corrupt search. This module is the loud guard for that hazard: a cheap * orphan-row count surfaced at open time (and reusable by `graphorin doctor`). * * NOTE: never run `VACUUM` on a Graphorin database. Use the export / rekey * maintenance path, which preserves rowids. * * @packageDocumentation */ import type { SqliteConnection } from './connection.js'; /** The FTS tables and the base tables they index, by rowid. */ const FTS_PAIRS: ReadonlyArray = [ ['facts_fts', 'facts'], ['episodes_fts', 'episodes'], ['session_messages_fts', 'session_messages'], ['insights_fts', 'insights'], // W-113: added with migration 028 - the guard must cover every FTS // index; the coverage self-check test enumerates `%_fts` tables from // sqlite_master so a future index cannot be forgotten silently. ['rules_fts', 'rules'], ]; /** * The FTS tables the integrity guard covers. Exported so the * coverage self-check test can diff this list against the `%_fts` * tables of a fully-migrated database - a new FTS index that is not * registered here fails the test instead of silently escaping the * integrity check. * * @internal */ export function listCheckedFtsTables(): ReadonlyArray { return FTS_PAIRS.map(([fts]) => fts); } /** One FTS table's integrity finding. */ export interface FtsIntegrityReport { /** The FTS table inspected. */ readonly table: string; /** FTS rows whose `rowid` matches no row in the base table. */ readonly orphanRows: number; } function tableExists(conn: SqliteConnection, name: string): boolean { return ( conn.get<{ n: number }>('SELECT COUNT(*) AS n FROM sqlite_master WHERE name = ?', [name])?.n !== 0 ); } /** * Count orphaned FTS rows (rowids with no matching base row) for every FTS * table that exists. An empty array means every FTS index is consistent with * its base table. A non-empty result is a sign of rowid drift - most likely a * hand-run `VACUUM` - and means search may return the wrong records. * * Tables absent from the schema (e.g. before their migration has run) are * skipped rather than reported. * * @stable */ export function checkFtsIntegrity(conn: SqliteConnection): FtsIntegrityReport[] { const reports: FtsIntegrityReport[] = []; for (const [fts, base] of FTS_PAIRS) { if (!tableExists(conn, fts) || !tableExists(conn, base)) continue; const orphanRows = conn.get<{ n: number }>( `SELECT COUNT(*) AS n FROM ${fts} WHERE rowid NOT IN (SELECT rowid FROM ${base})`, )?.n ?? 0; if (orphanRows > 0) reports.push({ table: fts, orphanRows }); } return reports; } /** * Format an {@link checkFtsIntegrity} result as a single warning line, or * `null` when the indexes are consistent. Used at store-open time. */ export function formatFtsIntegrityWarning( reports: ReadonlyArray, ): string | null { if (reports.length === 0) return null; const detail = reports.map((r) => `${r.table}: ${r.orphanRows} orphan row(s)`).join('; '); return ( `[graphorin/store-sqlite] FTS index integrity check found drift (${detail}). ` + 'This usually means VACUUM was run on the database - never VACUUM a Graphorin ' + 'store; use the export/rekey maintenance path, which preserves rowids. ' + 'Rebuild the affected FTS index to restore correct search results.' ); }