From 870b275e80db64ad5621df4e3f54c387b04fdf53 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 18 Aug 2026 11:14:24 +0200 Subject: [PATCH] feat(db): report the SQLite driver and its durability on the DB health check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DbHealthCheckResult` said whether the data was consistent but never which engine was serving it. The driver cascade logs its choice once at startup and `omniroute doctor` checks whether the better-sqlite3 native binary loads on disk from its own process — neither answers what the running server is on, and the two can disagree (a bundler that rewrites the runtime require makes the native driver fail to resolve inside the server while a fresh CLI resolves it fine, leaving the server silently on the sql.js WASM fallback). `driver.degraded` carries exactly one meaning: writes are not durably backed by the database file. Two situations qualify — the sql.js WASM fallback, and an in-memory database, which `getDbInstance()` opens in cloud/build mode through the NATIVE cascade, so the driver name alone would read as healthy while nothing is persisted. The field is informative only: `isHealthy` stays defined by `issues`, so no existing check changes verdict. It reaches the authenticated `GET/POST /api/db/health` and the loopback-only `omniroute_db_health_check` MCP tool, which already serialize the whole result. Also corrects the DATABASE_GUIDE health-check section, which documented a `{status, checks: {...}}` payload this endpoint has never returned (`orphaned_artifacts` and `table_sizes` have no source match anywhere). Its unterminated code fence left every fabricated-docs claim below it unscanned; closing the fence surfaced `SQLITE_FULL`, a SQLite result code the gate reads as an env var, now allowlisted alongside the other prose-documented error codes. --- docs/ops/DATABASE_GUIDE.md | 49 +++++++++++----- scripts/check/check-fabricated-docs.mjs | 1 + src/lib/db/healthCheck.ts | 34 +++++++++-- tests/unit/db-health-driver.test.ts | 75 +++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 tests/unit/db-health-driver.test.ts diff --git a/docs/ops/DATABASE_GUIDE.md b/docs/ops/DATABASE_GUIDE.md index e0a0292a05f..55d3771ce6f 100644 --- a/docs/ops/DATABASE_GUIDE.md +++ b/docs/ops/DATABASE_GUIDE.md @@ -453,26 +453,47 @@ Run monthly during low-traffic windows. (WAL mode reduces the need, but doesn't `src/lib/db/healthCheck.ts` provides **DB-level health diagnostics**: -````bash -GET /api/db/health +Both verbs require authentication (`401` otherwise). `GET` diagnoses only; `POST` runs the +same check with `autoRepair` enabled. -Returns: +```bash +GET /api/db/health # diagnose +POST /api/db/health # diagnose + repair +``` + +The response is the `DbHealthCheckResult` produced by `runDbHealthCheck()` +(`src/lib/db/healthCheck.ts`): ```json { - "status": "healthy", - "checks": { - "writable": { "status": "pass" }, - "integrity": { "status": "pass", "result": "ok" }, - "foreign_keys": { "status": "pass", "violations": 0 }, - "orphaned_artifacts": { "status": "warn", "count": 12 }, - "table_sizes": { - "usage_history": { "rows": 12345, "size_mb": 12.3 }, - "call_logs": { "rows": 567, "size_mb": 2.1 } + "isHealthy": false, + "issues": [ + { + "type": "broken_reference", + "table": "domain_budgets", + "description": "Domain budgets referenced API keys that no longer exist.", + "count": 2 } - } + ], + "repairedCount": 0, + "backupCreated": false, + "autoRepair": false, + "checkedAt": "2026-08-18T09:00:00.000Z", + "driver": { "name": "better-sqlite3", "degraded": false } } -```` +``` + +| Field | Meaning | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `isHealthy` | `true` when `issues` is empty. `driver` never influences it. | +| `issues[].type` | One of `integrity_check_failed`, `broken_reference`, `stale_snapshot`, `invalid_state`. | +| `repairedCount` | Rows repaired during this run; always `0` when `autoRepair` is false. | +| `backupCreated` | Whether a backup was taken before repairing. | +| `checkedAt` | ISO timestamp shared by the run and by any repair note it writes. | +| `driver.name` | SQLite driver serving the checked database. | +| `driver.degraded` | `true` when writes are not durably backed by the database file — the `sql.js` WASM fallback (whole-file persistence) or an in-memory database. | + +The same payload is returned by the `omniroute_db_health_check` MCP tool. Run `PRAGMA integrity_check` to detect corruption: diff --git a/scripts/check/check-fabricated-docs.mjs b/scripts/check/check-fabricated-docs.mjs index aba493b7d4b..6b8ef1ed342 100644 --- a/scripts/check/check-fabricated-docs.mjs +++ b/scripts/check/check-fabricated-docs.mjs @@ -314,6 +314,7 @@ const ENV_VAR_DENYLIST = new Set([ "AUTHZ_NOT_INITIALIZED", // AuthzAssertionError code (AUTHZ_GUIDE.md) "MODULE_NOT_FOUND", // Node runtime error code watched by service supervisor (ELECTRON_GUIDE.md) "ERR_DLOPEN_FAILED", // Node native-module load error code (ELECTRON_GUIDE.md) + "SQLITE_FULL", // SQLite result code returned when the disk is full (DATABASE_GUIDE.md) // ── Code-symbol / naming-convention examples documented in prose ───────────── "UPPER_SNAKE", // the literal naming-convention token in the style guide (CODEBASE_DOCUMENTATION.md) "DEFAULT_TIMEOUT", // example constant name in the UPPER_SNAKE convention row (AGENTS.md) diff --git a/src/lib/db/healthCheck.ts b/src/lib/db/healthCheck.ts index be0a9ee45ee..39165800361 100644 --- a/src/lib/db/healthCheck.ts +++ b/src/lib/db/healthCheck.ts @@ -5,10 +5,7 @@ type SqliteDatabase = SqliteAdapter; type JsonRecord = Record; export type DbHealthIssueType = - | "integrity_check_failed" - | "broken_reference" - | "stale_snapshot" - | "invalid_state"; + "integrity_check_failed" | "broken_reference" | "stale_snapshot" | "invalid_state"; export interface DbHealthIssue { type: DbHealthIssueType; @@ -17,6 +14,20 @@ export interface DbHealthIssue { count: number; } +/** Derived from the adapter contract so a new driver cannot drift out of sync here. */ +export type DbDriverName = SqliteAdapter["driver"]; + +export interface DbDriverHealth { + name: DbDriverName; + /** + * True when writes are not durably backed by the database file: the `sql.js` WASM + * fallback, or an in-memory database — which the cloud/build path opens through the + * NATIVE cascade, so the driver name alone would read as healthy. + * Informative only; `isHealthy` stays defined by `issues`. + */ + degraded: boolean; +} + export interface DbHealthCheckResult { isHealthy: boolean; issues: DbHealthIssue[]; @@ -24,6 +35,17 @@ export interface DbHealthCheckResult { backupCreated: boolean; autoRepair: boolean; checkedAt: string; + driver: DbDriverHealth; +} + +const IN_MEMORY_DB_NAME = ":memory:"; + +/** PURE: describe the driver serving `db`, and whether its writes survive a crash. */ +export function describeDbDriver(db: Pick): DbDriverHealth { + return { + name: db.driver, + degraded: db.driver === "sql.js" || db.name === IN_MEMORY_DB_NAME, + }; } interface RunDbHealthCheckOptions { @@ -383,8 +405,7 @@ function repairInvalidJsonRows( function getSchemaVersionIssueCount(db: SqliteDatabase, expectedSchemaVersion: string): number { if (!hasRows(db, "db_meta")) return 0; const row = db.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'").get() as - | { value?: string | null } - | undefined; + { value?: string | null } | undefined; const current = typeof row?.value === "string" ? row.value : null; return current === expectedSchemaVersion ? 0 : 1; } @@ -561,5 +582,6 @@ export function runDbHealthCheck( backupCreated, autoRepair, checkedAt, + driver: describeDbDriver(db), }; } diff --git a/tests/unit/db-health-driver.test.ts b/tests/unit/db-health-driver.test.ts new file mode 100644 index 00000000000..efbc6b08f62 --- /dev/null +++ b/tests/unit/db-health-driver.test.ts @@ -0,0 +1,75 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-health-driver-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "db-health-driver-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const healthCheckDb = await import("../../src/lib/db/healthCheck.ts"); +const driverFactory = await import("../../src/lib/db/adapters/driverFactory.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── describeDbDriver: pure decision ─────────────────── + +test("the sql.js WASM fallback is reported degraded", () => { + assert.deepEqual( + healthCheckDb.describeDbDriver({ driver: "sql.js", name: "/data/storage.sqlite" }), + { + name: "sql.js", + degraded: true, + } + ); +}); + +test("a native driver backed by a real file is not degraded", () => { + for (const driver of ["better-sqlite3", "node:sqlite", "bun:sqlite"] as const) { + assert.deepEqual(healthCheckDb.describeDbDriver({ driver, name: "/data/storage.sqlite" }), { + name: driver, + degraded: false, + }); + } +}); + +test("an in-memory database is degraded even on a native driver", () => { + for (const driver of ["better-sqlite3", "node:sqlite", "bun:sqlite", "sql.js"] as const) { + assert.deepEqual(healthCheckDb.describeDbDriver({ driver, name: ":memory:" }), { + name: driver, + degraded: true, + }); + } +}); + +// ─── runDbHealthCheck: wiring against ground truth ───── + +test("the health check reports the driver of the database it actually ran against", () => { + const db = core.getDbInstance(); + const result = healthCheckDb.runDbHealthCheck(db, { autoRepair: false }); + + assert.equal(result.driver.name, db.driver); + + // Precondition: the fixture is file-backed, so `degraded` is asserted as a literal + // rather than re-derived from the implementation's own condition. + assert.ok(db.name.endsWith(".sqlite"), `expected a file-backed fixture, got ${db.name}`); + assert.equal(result.driver.degraded, false); +}); + +test("an in-memory database opened through the real cascade is reported degraded", () => { + // Same tryOpenSync() the cloud/build path reaches through openSqliteDatabase(), so the + // `:memory:` name is observed from the adapter rather than assumed. + const memoryAdapter = driverFactory.tryOpenSync(":memory:"); + assert.ok(memoryAdapter, "expected the native cascade to open an in-memory database"); + try { + assert.equal(memoryAdapter.name, ":memory:"); + assert.equal(healthCheckDb.describeDbDriver(memoryAdapter).degraded, true); + } finally { + memoryAdapter.close(); + } +});