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
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | Path to payload manipulation rules JSON file (per-model/protocol upstream tweaks). |
| `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | Reload interval (ms) for hot-reloading the payload rules file. Minimum `1000`. |
| `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | Opt-in: route bare `claude-*` model IDs from Claude Code clients through the Claude Code OAuth account instead of requiring a provider prefix. Explicit provider prefixes still win. Also configurable via a dashboard toggle on the Claude provider page. |
| `CLAUDE_CC_ENTRYPOINT` | `cli` | `open-sse/config/anthropicHeaders.ts` | Native Claude OAuth billing and User-Agent entrypoint. Accepted values: `cli` and `sdk-cli`. Invalid values fall back to `cli`. API-key requests retain the static `cli` identity. |

---

Expand Down
39 changes: 34 additions & 5 deletions src/lib/db/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,28 @@ export interface DatabaseStats {
cacheSize: number;
}

/**
* `dbstat` is a compile-time-optional SQLite virtual table (ENABLE_DBSTAT_VTAB).
* Builds without it — sql.js/WASM among them — reject the query with either
* "no such module: dbstat" or "no such table: dbstat" depending on the build,
* and drivers prefix their error class onto the message, so match loosely.
*
* Per-table byte sizes are a nice-to-have, so probe once and degrade to 0
* rather than failing the whole stats call — and with it every caller,
* including the database settings API.
*/
function isDbstatAvailable(db: SqliteAdapter): boolean {
try {
db.prepare(`SELECT SUM(pgsize) as size FROM dbstat WHERE name = ?`).get("sqlite_master");
return true;
} catch (error) {
if (error instanceof Error && /no such (module|table): dbstat/i.test(error.message)) {
return false;
}
throw error;
}
}

export function getDatabaseStats(db: SqliteAdapter = getDbInstance()): DatabaseStats {
const pageSize = db.pragma("page_size", { simple: true }) as number;
const pageCount = db.pragma("page_count", { simple: true }) as number;
Expand All @@ -36,12 +58,15 @@ export function getDatabaseStats(db: SqliteAdapter = getDbInstance()): DatabaseS
)
.all() as Array<{ name: string }>;

const dbstatAvailable = isDbstatAvailable(db);

const tableStats = tables.map((table) => {
let rowCount = 0;
try {
const quotedName = `"${table.name.replaceAll('"', '""')}"`;
const row = db.prepare(`SELECT COUNT(*) as count FROM ${quotedName}`).get() as
{ count: number } | undefined;
| { count: number }
| undefined;
rowCount = row?.count ?? 0;
} catch (error) {
if (!(error instanceof Error) || !error.message.startsWith("no such module:")) {
Expand All @@ -50,14 +75,18 @@ export function getDatabaseStats(db: SqliteAdapter = getDbInstance()): DatabaseS
// Optional virtual-table modules may be unavailable on this connection.
}

const tableSize = db
.prepare(`SELECT SUM(pgsize) as size FROM dbstat WHERE name = ?`)
.get(table.name) as { size: number | null };
let size = 0;
if (dbstatAvailable) {
const tableSize = db
.prepare(`SELECT SUM(pgsize) as size FROM dbstat WHERE name = ?`)
.get(table.name) as { size: number | null } | undefined;
size = tableSize?.size || 0;
}

return {
name: table.name,
rowCount,
size: tableSize?.size || 0,
size,
};
});

Expand Down
11 changes: 3 additions & 8 deletions stryker.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@
"incremental": true,
"incrementalFile": "reports/mutation/stryker-incremental.json",
"testRunner": "tap",
"plugins": [
"@stryker-mutator/tap-runner"
],
"plugins": ["@stryker-mutator/tap-runner"],
"tap": {
"testFiles": [
"tests/unit/7993-noauth-proxy-routing.test.ts",
Expand Down Expand Up @@ -291,6 +289,7 @@
"tests/unit/sse-auth-antigravity-credits.test.ts",
"tests/unit/sse-auth-resource-404.test.ts",
"tests/unit/sse-auth.test.ts",
"tests/unit/db/stats-dbstat-optional.test.ts",
"tests/unit/stream-early-eof-breaker.test.ts",
"tests/unit/strict-random-deck.test.ts",
"tests/unit/strip-reasoning-header.test.ts",
Expand Down Expand Up @@ -417,11 +416,7 @@
".worktrees",
".stryker-tmp"
],
"reporters": [
"progress",
"html",
"json"
],
"reporters": ["progress", "html", "json"],
"htmlReporter": {
"fileName": "reports/mutation/mutation.html"
},
Expand Down
189 changes: 189 additions & 0 deletions tests/unit/db/stats-dbstat-optional.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* getDatabaseStats() must survive a SQLite build without the `dbstat` virtual
* table.
*
* `dbstat` is compile-time optional (ENABLE_DBSTAT_VTAB) and is absent from
* sql.js/WASM builds. Before the fix, the unguarded per-table `SELECT SUM(pgsize)
* FROM dbstat` threw, which propagated out of getDatabaseStats() and made
* GET/PATCH /api/settings/database return HTTP 500 — the whole database settings
* page became unusable on those runtimes.
*/

import assert from "node:assert/strict";
import { test } from "node:test";

import { getDatabaseStats } from "@/lib/db/stats";
import type { PreparedStatement, SqliteAdapter } from "@/lib/db/adapters/types";

type FakeOptions = {
/** Error message thrown by any statement touching `dbstat`. */
dbstatError?: string;
/** Tables reported by sqlite_master. */
tables?: string[];
/** Make the dbstat probe succeed but fail for this specific table. */
failOnlyOn?: string;
/** Return `{ size: null }` from dbstat, as SUM() does over an empty table. */
nullSize?: boolean;
};

/**
* Minimal in-memory SqliteAdapter double. Only the surface getDatabaseStats()
* actually touches is implemented; everything else throws so an accidental new
* dependency shows up loudly instead of silently passing.
*/
function createFakeDb({
dbstatError,
tables = ["alpha", "beta"],
failOnlyOn,
nullSize,
}: FakeOptions = {}): SqliteAdapter {
const prepare = (sql: string): PreparedStatement => {
const touchesDbstat = /\bdbstat\b/i.test(sql);

return {
run() {
throw new Error(`unexpected run(): ${sql}`);
},
get(...params: unknown[]) {
if (touchesDbstat) {
const probing = params[0] === "sqlite_master";
// `failOnlyOn` models a driver that answers the probe but fails later.
if (failOnlyOn) {
if (params[0] === failOnlyOn) throw new Error(dbstatError ?? "no such table: dbstat");
} else if (dbstatError) {
throw new Error(dbstatError);
}
if (probing) return { size: 0 };
return { size: nullSize ? null : 4096 };
}
if (/COUNT\(\*\)/i.test(sql)) return { count: 7 };
throw new Error(`unexpected get(): ${sql}`);
},
all() {
if (/type='table'/i.test(sql)) return tables.map((name) => ({ name }));
if (/type='index'/i.test(sql)) {
return tables.length ? [{ name: "idx_alpha", tableName: "alpha" }] : [];
}
throw new Error(`unexpected all(): ${sql}`);
},
};
};

return {
driver: "sql.js",
open: true,
name: ":memory:",
prepare,
exec() {},
pragma(pragmaStr: string) {
if (pragmaStr === "page_size") return 4096;
if (pragmaStr === "page_count") return 100;
if (pragmaStr === "cache_size") return -65536;
throw new Error(`unexpected pragma: ${pragmaStr}`);
},
transaction<T>(fn: (...args: unknown[]) => T) {
return fn;
},
immediate(fn: () => void) {
fn();
},
async backup() {},
checkpoint() {},
close() {},
raw: null,
} satisfies SqliteAdapter;
}

test("getDatabaseStats reports per-table sizes when dbstat is available", () => {
const stats = getDatabaseStats(createFakeDb());

assert.equal(stats.totalSize, 4096 * 100);
assert.deepEqual(
stats.tables.map((t) => [t.name, t.rowCount, t.size]),
[
["alpha", 7, 4096],
["beta", 7, 4096],
]
);
});

test("getDatabaseStats degrades to size 0 when dbstat module is missing", () => {
const stats = getDatabaseStats(createFakeDb({ dbstatError: "no such module: dbstat" }));

// The call must succeed; only per-table byte sizes are lost.
assert.deepEqual(
stats.tables.map((t) => [t.name, t.rowCount, t.size]),
[
["alpha", 7, 0],
["beta", 7, 0],
]
);
// Database-level numbers come from pragmas and stay accurate.
assert.equal(stats.totalSize, 4096 * 100);
assert.equal(stats.pageCount, 100);
assert.equal(stats.cacheSize, -65536);
assert.equal(stats.indexes.length, 1);
});

test("getDatabaseStats degrades when the driver reports dbstat as a missing table", () => {
// SQLite builds lacking ENABLE_DBSTAT_VTAB commonly report this variant.
const stats = getDatabaseStats(createFakeDb({ dbstatError: "no such table: dbstat" }));

assert.deepEqual(
stats.tables.map((t) => t.size),
[0, 0]
);
});

test("getDatabaseStats degrades when the driver prefixes its error class", () => {
// Real drivers stringify as "SqliteError: ..." / "RuntimeError: ...", so the
// guard must not be anchored to the start of the message.
for (const message of [
"SqliteError: no such table: dbstat",
"RuntimeError: no such module: dbstat",
]) {
const stats = getDatabaseStats(createFakeDb({ dbstatError: message }));
assert.deepEqual(
stats.tables.map((t) => t.size),
[0, 0],
`expected degradation for ${message}`
);
}
});

test("getDatabaseStats handles a database with no user tables", () => {
// The shape a fresh install hits before any migration has run.
const stats = getDatabaseStats(createFakeDb({ tables: [] }));

assert.deepEqual(stats.tables, []);
assert.deepEqual(stats.indexes, []);
assert.equal(stats.totalSize, 4096 * 100);
});

test("getDatabaseStats maps a NULL dbstat sum to 0", () => {
// SUM(pgsize) returns NULL when a table occupies no pages.
const stats = getDatabaseStats(createFakeDb({ nullSize: true }));

assert.deepEqual(
stats.tables.map((t) => t.size),
[0, 0]
);
});

test("getDatabaseStats propagates a dbstat failure that appears after the probe", () => {
// Documents current behaviour: the probe establishes availability once, so a
// later per-table failure is treated as a genuine fault rather than a missing
// module. Anything else would mask real I/O errors mid-iteration.
assert.throws(
() => getDatabaseStats(createFakeDb({ failOnlyOn: "beta" })),
/no such table: dbstat/
);
});

test("getDatabaseStats still propagates unrelated dbstat failures", () => {
// A genuine fault (disk I/O, corruption) must not be silently swallowed.
assert.throws(
() => getDatabaseStats(createFakeDb({ dbstatError: "database disk image is malformed" })),
/database disk image is malformed/
);
});
Loading