Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 35 additions & 14 deletions docs/ops/DATABASE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions scripts/check/check-fabricated-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 28 additions & 6 deletions src/lib/db/healthCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ type SqliteDatabase = SqliteAdapter;
type JsonRecord = Record<string, unknown>;

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;
Expand All @@ -17,13 +14,38 @@ 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[];
repairedCount: number;
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<SqliteAdapter, "driver" | "name">): DbDriverHealth {
return {
name: db.driver,
degraded: db.driver === "sql.js" || db.name === IN_MEMORY_DB_NAME,
};
}

interface RunDbHealthCheckOptions {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -561,5 +582,6 @@ export function runDbHealthCheck(
backupCreated,
autoRepair,
checkedAt,
driver: describeDbDriver(db),
};
}
75 changes: 75 additions & 0 deletions tests/unit/db-health-driver.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
Loading