Files
OmniRoute/src/lib/db/core.ts
diegosouzapw 72afecffeb fix(migrations): resolve version collisions and add batch deletion API (#2294)
- Rename 056_provider_connection_quota_window_thresholds.sql to 057
- Add LEGACY_VERSION_SLOT_MIGRATIONS entries for backward compatibility
- Add deleteBatch/deleteCompletedBatches to batches.ts
- Add DELETE routes for batches (single + bulk)
- Add batch deletion buttons to dashboard
- Broaden dashboard session auth to all client API routes
- Add quota_window_thresholds_json column repair

Authored-by: Markus Hartung <hartmark@users.noreply.github.com>
2026-05-16 00:27:20 -03:00

1746 lines
60 KiB
TypeScript

/**
* db/core.js — Database infrastructure: schema, singleton, utils, migration.
*
* All domain modules import `getDbInstance` and helpers from here.
*/
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import { resolveDataDir, getLegacyDotDataDir } from "../dataPaths";
import { runMigrations } from "./migrationRunner";
import { runDbHealthCheck } from "./healthCheck";
import { parseStoredPayload } from "../logPayloads";
import {
buildArtifactRelativePath,
writeCallArtifact,
type CallLogArtifact,
} from "../usage/callLogArtifacts";
import { migrateLegacyEncryptedString } from "./encryption";
import { invalidateDbCache } from "./readCache";
type SqliteDatabase = import("better-sqlite3").Database;
type JsonRecord = Record<string, unknown>;
type CheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE";
type PreservedTableSnapshot = {
table: string;
rowCount: number;
maxRows: number;
columns: string[];
rows: JsonRecord[];
};
type SkippedTableSnapshot = {
table: string;
rowCount: number;
maxRows: number;
reason: string;
};
type PreservedCriticalDbState = {
captureSucceeded: boolean;
captureError: string | null;
preservedTables: PreservedTableSnapshot[];
skippedTables: SkippedTableSnapshot[];
};
type CriticalTableSpec = {
table: string;
maxRows?: number;
readRows?: (db: SqliteDatabase) => JsonRecord[];
};
// ──────────────── Environment Detection ────────────────
export const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null;
export const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
// ──────────────── Paths ────────────────
export const DATA_DIR = resolveDataDir({ isCloud });
const LEGACY_DATA_DIR = isCloud ? null : getLegacyDotDataDir();
export const SQLITE_FILE = isCloud ? null : path.join(DATA_DIR, "storage.sqlite");
const JSON_DB_FILE = isCloud ? null : path.join(DATA_DIR, "db.json");
export const DB_BACKUPS_DIR = isCloud ? null : path.join(DATA_DIR, "db_backups");
const DEFAULT_CRITICAL_TABLE_ROW_LIMIT = 10_000;
const SKIP_PRESERVE_NAMESPACES = new Set(["syncedAvailableModels", "providerLimitsCache", "lkgp"]);
const CRITICAL_DB_TABLES: CriticalTableSpec[] = [
{
table: "key_value",
maxRows: 10_000,
readRows(db) {
return (
(db.prepare("SELECT namespace, key, value FROM key_value").all() as JsonRecord[]) ?? []
).filter(
(row) => typeof row.namespace !== "string" || !SKIP_PRESERVE_NAMESPACES.has(row.namespace)
);
},
},
{ table: "provider_connections", maxRows: 5_000 },
{ table: "provider_nodes", maxRows: 5_000 },
{ table: "combos", maxRows: 5_000 },
{ table: "api_keys", maxRows: 5_000 },
{ table: "proxy_registry", maxRows: 5_000 },
{ table: "proxy_assignments", maxRows: 10_000 },
{ table: "model_combo_mappings", maxRows: 5_000 },
{ table: "sync_tokens", maxRows: 5_000 },
{ table: "registered_keys", maxRows: 10_000 },
{ table: "provider_key_limits", maxRows: 10_000 },
{ table: "account_key_limits", maxRows: 10_000 },
{ table: "upstream_proxy_config", maxRows: 5_000 },
{ table: "webhooks", maxRows: 5_000 },
];
function isNativeSqliteLoadError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("Module did not self-register") ||
message.includes("NODE_MODULE_VERSION") ||
message.includes("ERR_DLOPEN_FAILED") ||
getErrorCode(error) === "ERR_DLOPEN_FAILED"
);
}
function getErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== "object" || !("code" in error)) return undefined;
const code = (error as { code?: unknown }).code;
return typeof code === "string" ? code : undefined;
}
function createNativeSqliteLoadError(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);
const detail =
`better-sqlite3 native binding failed to load for Node.js ${process.version}. ` +
"This usually happens after switching Node.js versions without rebuilding native modules. " +
"Run `npm rebuild better-sqlite3` in the OmniRoute project and start again. " +
`Original error: ${message}`;
const wrapped = new Error(detail) as Error & { cause?: unknown; code?: string };
wrapped.name = "NativeSqliteLoadError";
wrapped.cause = error;
wrapped.code = getErrorCode(error) || "ERR_DLOPEN_FAILED";
return wrapped;
}
function openSqliteDatabase(
sqliteFile: string,
options?: ConstructorParameters<typeof Database>[1]
): SqliteDatabase {
try {
return new Database(sqliteFile, options);
} catch (error: unknown) {
if (isNativeSqliteLoadError(error)) {
throw createNativeSqliteLoadError(error);
}
throw error;
}
}
// Ensure data directory exists — with fallback for restricted home directories (#133)
if (!isCloud && !fs.existsSync(DATA_DIR)) {
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(
`[DB] Cannot create data directory '${DATA_DIR}': ${msg}\n` +
`[DB] Set the DATA_DIR environment variable to a writable path, e.g.:\n` +
`[DB] DATA_DIR=/path/to/writable/dir omniroute`
);
}
}
// ──────────────── Schema ────────────────
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS provider_connections (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
auth_type TEXT,
name TEXT,
email TEXT,
priority INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
access_token TEXT,
refresh_token TEXT,
expires_at TEXT,
token_expires_at TEXT,
scope TEXT,
project_id TEXT,
test_status TEXT,
error_code TEXT,
last_error TEXT,
last_error_at TEXT,
last_error_type TEXT,
last_error_source TEXT,
backoff_level INTEGER DEFAULT 0,
rate_limited_until TEXT,
health_check_interval INTEGER,
last_health_check_at TEXT,
last_tested TEXT,
api_key TEXT,
id_token TEXT,
provider_specific_data TEXT,
expires_in INTEGER,
display_name TEXT,
global_priority INTEGER,
default_model TEXT,
token_type TEXT,
consecutive_use_count INTEGER DEFAULT 0,
rate_limit_protection INTEGER DEFAULT 0,
last_used_at TEXT,
"group" TEXT,
max_concurrent INTEGER,
quota_window_thresholds_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pc_provider ON provider_connections(provider);
CREATE INDEX IF NOT EXISTS idx_pc_active ON provider_connections(is_active);
CREATE INDEX IF NOT EXISTS idx_pc_priority ON provider_connections(provider, priority);
CREATE TABLE IF NOT EXISTS provider_nodes (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
prefix TEXT,
api_type TEXT,
base_url TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS key_value (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (namespace, key)
);
CREATE TABLE IF NOT EXISTS combos (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
data TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
key TEXT NOT NULL UNIQUE,
machine_id TEXT,
allowed_models TEXT DEFAULT '[]',
no_log INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ak_key ON api_keys(key);
CREATE TABLE IF NOT EXISTS db_meta (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS usage_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT,
model TEXT,
connection_id TEXT,
api_key_id TEXT,
api_key_name TEXT,
tokens_input INTEGER DEFAULT 0,
tokens_output INTEGER DEFAULT 0,
tokens_cache_read INTEGER DEFAULT 0,
tokens_cache_creation INTEGER DEFAULT 0,
tokens_reasoning INTEGER DEFAULT 0,
service_tier TEXT DEFAULT 'standard',
status TEXT,
success INTEGER DEFAULT 1,
latency_ms INTEGER DEFAULT 0,
ttft_ms INTEGER DEFAULT 0,
error_code TEXT,
timestamp TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_uh_timestamp ON usage_history(timestamp);
CREATE INDEX IF NOT EXISTS idx_uh_provider ON usage_history(provider);
CREATE INDEX IF NOT EXISTS idx_uh_model ON usage_history(model);
CREATE TABLE IF NOT EXISTS call_logs (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
method TEXT,
path TEXT,
status INTEGER,
model TEXT,
requested_model TEXT,
provider TEXT,
account TEXT,
connection_id TEXT,
duration INTEGER DEFAULT 0,
tokens_in INTEGER DEFAULT 0,
tokens_out INTEGER DEFAULT 0,
tokens_cache_read INTEGER DEFAULT NULL,
tokens_cache_creation INTEGER DEFAULT NULL,
tokens_reasoning INTEGER DEFAULT NULL,
tokens_compressed INTEGER DEFAULT NULL,
cache_source TEXT DEFAULT "upstream",
request_type TEXT,
source_format TEXT,
target_format TEXT,
api_key_id TEXT,
api_key_name TEXT,
combo_name TEXT,
combo_step_id TEXT,
combo_execution_key TEXT,
error_summary TEXT,
detail_state TEXT DEFAULT 'none',
artifact_relpath TEXT,
artifact_size_bytes INTEGER DEFAULT NULL,
artifact_sha256 TEXT DEFAULT NULL,
has_request_body INTEGER DEFAULT 0,
has_response_body INTEGER DEFAULT 0,
has_pipeline_details INTEGER DEFAULT 0,
request_summary TEXT
);
CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status);
CREATE TABLE IF NOT EXISTS proxy_logs (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
status TEXT,
proxy_type TEXT,
proxy_host TEXT,
proxy_port INTEGER,
level TEXT,
level_id TEXT,
provider TEXT,
target_url TEXT,
public_ip TEXT,
latency_ms INTEGER DEFAULT 0,
error TEXT,
connection_id TEXT,
combo_id TEXT,
account TEXT,
tls_fingerprint INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_pl_timestamp ON proxy_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_pl_status ON proxy_logs(status);
CREATE INDEX IF NOT EXISTS idx_pl_provider ON proxy_logs(provider);
-- Domain State Persistence (Phase 5)
CREATE TABLE IF NOT EXISTS domain_fallback_chains (
model TEXT PRIMARY KEY,
chain TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS domain_budgets (
api_key_id TEXT PRIMARY KEY,
daily_limit_usd REAL NOT NULL,
weekly_limit_usd REAL DEFAULT 0,
monthly_limit_usd REAL DEFAULT 0,
warning_threshold REAL DEFAULT 0.8,
reset_interval TEXT DEFAULT 'daily',
reset_time TEXT DEFAULT '00:00',
budget_reset_at INTEGER,
last_budget_reset_at INTEGER,
warning_emitted_at INTEGER,
warning_period_start INTEGER
);
CREATE TABLE IF NOT EXISTS domain_budget_reset_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_key_id TEXT NOT NULL,
reset_interval TEXT NOT NULL,
previous_spend REAL NOT NULL DEFAULT 0,
reset_at INTEGER NOT NULL,
next_reset_at INTEGER NOT NULL,
period_start INTEGER NOT NULL,
period_end INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dbrl_key_reset ON domain_budget_reset_logs(api_key_id, reset_at DESC);
CREATE TABLE IF NOT EXISTS domain_cost_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_key_id TEXT NOT NULL,
cost REAL NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dch_key ON domain_cost_history(api_key_id);
CREATE INDEX IF NOT EXISTS idx_dch_ts ON domain_cost_history(timestamp);
CREATE TABLE IF NOT EXISTS domain_lockout_state (
identifier TEXT PRIMARY KEY,
attempts TEXT NOT NULL,
locked_until INTEGER
);
CREATE TABLE IF NOT EXISTS domain_circuit_breakers (
name TEXT PRIMARY KEY,
state TEXT NOT NULL DEFAULT 'CLOSED',
failure_count INTEGER DEFAULT 0,
last_failure_time INTEGER,
options TEXT
);
CREATE TABLE IF NOT EXISTS semantic_cache (
id TEXT PRIMARY KEY,
signature TEXT NOT NULL UNIQUE,
model TEXT NOT NULL,
prompt_hash TEXT NOT NULL,
response TEXT NOT NULL,
tokens_saved INTEGER DEFAULT 0,
hit_count INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sc_sig ON semantic_cache(signature);
CREATE INDEX IF NOT EXISTS idx_sc_model ON semantic_cache(model);
CREATE TABLE IF NOT EXISTS quota_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
connection_id TEXT NOT NULL,
window_key TEXT NOT NULL,
remaining_percentage REAL,
is_exhausted INTEGER DEFAULT 0,
next_reset_at TEXT,
window_duration_ms INTEGER,
raw_data TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_quota_snapshots_provider_time ON quota_snapshots(provider, created_at);
CREATE INDEX IF NOT EXISTS idx_quota_snapshots_connection_time ON quota_snapshots(connection_id, created_at);
CREATE INDEX IF NOT EXISTS idx_quota_snapshots_created_at ON quota_snapshots(created_at);
`;
// ──────────────── Column Mapping ────────────────
export function toSnakeCase(str: string): string {
return str.replace(/([A-Z])/g, "_$1").toLowerCase();
}
export function toCamelCase(str: string): string {
return str.replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase());
}
export function objToSnake(obj: unknown): unknown {
if (!obj || typeof obj !== "object") return obj;
const result: JsonRecord = {};
for (const [k, v] of Object.entries(obj as JsonRecord)) {
result[toSnakeCase(k)] = v;
}
return result;
}
export function rowToCamel(row: unknown): JsonRecord | null {
if (!row) return null;
const result: JsonRecord = {};
for (const [k, v] of Object.entries(row as JsonRecord)) {
const camelKey = toCamelCase(k);
if (camelKey === "isActive" || camelKey === "rateLimitProtection") {
result[camelKey] = v === 1 || v === true;
} else if (camelKey === "providerSpecificData" && typeof v === "string") {
try {
result[camelKey] = JSON.parse(v);
} catch {
result[camelKey] = v;
}
} else if (camelKey.endsWith("Json") && typeof v === "string") {
// Convention: any column with a `_json` suffix is JSON-encoded TEXT.
// Surface the parsed object under the friendlier name (key minus the
// "Json" suffix) — e.g. quotaWindowThresholdsJson → quotaWindowThresholds.
const baseKey = camelKey.slice(0, -"Json".length);
try {
result[baseKey] = JSON.parse(v);
} catch {
result[baseKey] = null;
}
} else {
result[camelKey] = v;
}
}
return result;
}
export function cleanNulls(obj: unknown): JsonRecord {
const result: JsonRecord = {};
for (const [k, v] of Object.entries((obj as JsonRecord) || {})) {
if (v !== null && v !== undefined) {
result[k] = v;
}
}
return result;
}
// ──────────────── Singleton DB Instance ────────────────
// Use globalThis to survive Next.js dev HMR module re-evaluation.
// Module-level `let` resets on every webpack recompile, causing connection leaks.
declare global {
var __omnirouteDb: import("better-sqlite3").Database | undefined;
}
function getDb(): SqliteDatabase | null {
return globalThis.__omnirouteDb ?? null;
}
function setDb(db: SqliteDatabase | null): void {
if (db) {
globalThis.__omnirouteDb = db;
} else {
delete globalThis.__omnirouteDb;
}
}
function checkpointDb(db: SqliteDatabase, mode: CheckpointMode = "TRUNCATE"): boolean {
if (isCloud || isBuildPhase || !SQLITE_FILE) return false;
db.pragma(`wal_checkpoint(${mode})`);
return true;
}
function ensureProviderConnectionsColumns(db: SqliteDatabase) {
try {
const columns = db.prepare("PRAGMA table_info(provider_connections)").all() as Array<{
name?: string;
}>;
const columnNames = new Set(columns.map((column) => String(column.name ?? "")));
if (!columnNames.has("rate_limit_protection")) {
db.exec(
"ALTER TABLE provider_connections ADD COLUMN rate_limit_protection INTEGER DEFAULT 0"
);
console.log("[DB] Added provider_connections.rate_limit_protection column");
}
if (!columnNames.has("last_used_at")) {
db.exec("ALTER TABLE provider_connections ADD COLUMN last_used_at TEXT");
console.log("[DB] Added provider_connections.last_used_at column");
}
if (!columnNames.has("group")) {
db.exec('ALTER TABLE provider_connections ADD COLUMN "group" TEXT');
console.log('[DB] Added provider_connections."group" column');
}
if (!columnNames.has("max_concurrent")) {
db.exec("ALTER TABLE provider_connections ADD COLUMN max_concurrent INTEGER");
console.log("[DB] Added provider_connections.max_concurrent column");
}
if (!columnNames.has("quota_window_thresholds_json")) {
db.exec("ALTER TABLE provider_connections ADD COLUMN quota_window_thresholds_json TEXT");
console.log("[DB] Added provider_connections.quota_window_thresholds_json column");
}
db.exec(
"CREATE INDEX IF NOT EXISTS idx_pc_max_concurrent ON provider_connections(provider, max_concurrent)"
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn("[DB] Failed to verify provider_connections schema:", message);
}
}
function ensureUsageHistoryColumns(db: SqliteDatabase) {
try {
const columns = db.prepare("PRAGMA table_info(usage_history)").all() as Array<{
name?: string;
}>;
const columnNames = new Set(columns.map((column) => String(column.name ?? "")));
if (!columnNames.has("success")) {
db.exec("ALTER TABLE usage_history ADD COLUMN success INTEGER DEFAULT 1");
console.log("[DB] Added usage_history.success column");
}
if (!columnNames.has("latency_ms")) {
db.exec("ALTER TABLE usage_history ADD COLUMN latency_ms INTEGER DEFAULT 0");
console.log("[DB] Added usage_history.latency_ms column");
}
if (!columnNames.has("ttft_ms")) {
db.exec("ALTER TABLE usage_history ADD COLUMN ttft_ms INTEGER DEFAULT 0");
console.log("[DB] Added usage_history.ttft_ms column");
}
if (!columnNames.has("error_code")) {
db.exec("ALTER TABLE usage_history ADD COLUMN error_code TEXT");
console.log("[DB] Added usage_history.error_code column");
}
if (!columnNames.has("service_tier")) {
db.exec("ALTER TABLE usage_history ADD COLUMN service_tier TEXT DEFAULT 'standard'");
console.log("[DB] Added usage_history.service_tier column");
}
db.exec("CREATE INDEX IF NOT EXISTS idx_uh_service_tier ON usage_history(service_tier)");
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn("[DB] Failed to verify usage_history schema:", message);
}
}
function ensureCallLogsColumns(db: SqliteDatabase) {
try {
const columns = db.prepare("PRAGMA table_info(call_logs)").all() as Array<{
name?: string;
}>;
const columnNames = new Set(columns.map((column) => String(column.name ?? "")));
if (!columnNames.has("artifact_relpath")) {
db.exec("ALTER TABLE call_logs ADD COLUMN artifact_relpath TEXT");
console.log("[DB] Added call_logs.artifact_relpath column");
}
if (!columnNames.has("has_pipeline_details")) {
db.exec("ALTER TABLE call_logs ADD COLUMN has_pipeline_details INTEGER DEFAULT 0");
console.log("[DB] Added call_logs.has_pipeline_details column");
}
if (!columnNames.has("requested_model")) {
db.exec("ALTER TABLE call_logs ADD COLUMN requested_model TEXT DEFAULT NULL");
console.log("[DB] Added call_logs.requested_model column");
}
if (!columnNames.has("request_type")) {
db.exec("ALTER TABLE call_logs ADD COLUMN request_type TEXT DEFAULT NULL");
console.log("[DB] Added call_logs.request_type column");
}
if (!columnNames.has("tokens_cache_read")) {
db.exec("ALTER TABLE call_logs ADD COLUMN tokens_cache_read INTEGER DEFAULT NULL");
console.log("[DB] Added call_logs.tokens_cache_read column");
}
if (!columnNames.has("tokens_cache_creation")) {
db.exec("ALTER TABLE call_logs ADD COLUMN tokens_cache_creation INTEGER DEFAULT NULL");
console.log("[DB] Added call_logs.tokens_cache_creation column");
}
if (!columnNames.has("tokens_reasoning")) {
db.exec("ALTER TABLE call_logs ADD COLUMN tokens_reasoning INTEGER DEFAULT NULL");
console.log("[DB] Added call_logs.tokens_reasoning column");
}
if (!columnNames.has("cache_source")) {
db.exec("ALTER TABLE call_logs ADD COLUMN cache_source TEXT DEFAULT 'upstream'");
console.log("[DB] Added call_logs.cache_source column");
}
if (!columnNames.has("combo_step_id")) {
db.exec("ALTER TABLE call_logs ADD COLUMN combo_step_id TEXT DEFAULT NULL");
console.log("[DB] Added call_logs.combo_step_id column");
}
if (!columnNames.has("combo_execution_key")) {
db.exec("ALTER TABLE call_logs ADD COLUMN combo_execution_key TEXT DEFAULT NULL");
console.log("[DB] Added call_logs.combo_execution_key column");
}
if (!columnNames.has("error_summary")) {
db.exec("ALTER TABLE call_logs ADD COLUMN error_summary TEXT DEFAULT NULL");
console.log("[DB] Added call_logs.error_summary column");
}
if (!columnNames.has("detail_state")) {
db.exec("ALTER TABLE call_logs ADD COLUMN detail_state TEXT DEFAULT 'none'");
console.log("[DB] Added call_logs.detail_state column");
}
if (!columnNames.has("artifact_size_bytes")) {
db.exec("ALTER TABLE call_logs ADD COLUMN artifact_size_bytes INTEGER DEFAULT NULL");
console.log("[DB] Added call_logs.artifact_size_bytes column");
}
if (!columnNames.has("artifact_sha256")) {
db.exec("ALTER TABLE call_logs ADD COLUMN artifact_sha256 TEXT DEFAULT NULL");
console.log("[DB] Added call_logs.artifact_sha256 column");
}
if (!columnNames.has("has_request_body")) {
db.exec("ALTER TABLE call_logs ADD COLUMN has_request_body INTEGER DEFAULT 0");
console.log("[DB] Added call_logs.has_request_body column");
}
if (!columnNames.has("has_response_body")) {
db.exec("ALTER TABLE call_logs ADD COLUMN has_response_body INTEGER DEFAULT 0");
console.log("[DB] Added call_logs.has_response_body column");
}
if (!columnNames.has("request_summary")) {
db.exec("ALTER TABLE call_logs ADD COLUMN request_summary TEXT DEFAULT NULL");
console.log("[DB] Added call_logs.request_summary column");
}
db.exec(
"CREATE INDEX IF NOT EXISTS idx_call_logs_requested_model ON call_logs(requested_model)"
);
db.exec("CREATE INDEX IF NOT EXISTS idx_call_logs_request_type ON call_logs(request_type)");
db.exec(
"CREATE INDEX IF NOT EXISTS idx_cl_combo_target ON call_logs(combo_name, combo_execution_key, timestamp)"
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn("[DB] Failed to verify call_logs schema:", message);
}
}
function hasColumn(db: SqliteDatabase, tableName: string, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>;
return rows.some((row) => row.name === columnName);
}
function hasTable(db: SqliteDatabase, tableName: string): boolean {
return Boolean(
db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName)
);
}
function quoteIdentifier(identifier: string): string {
return `"${identifier.replace(/"/g, '""')}"`;
}
function getTableColumns(db: SqliteDatabase, tableName: string): string[] {
return (
db.prepare(`PRAGMA table_info(${quoteIdentifier(tableName)})`).all() as Array<{ name?: string }>
)
.map((column) => String(column.name ?? ""))
.filter((column) => column.length > 0);
}
function summarizePreservedTables(tables: PreservedTableSnapshot[]): string {
if (tables.length === 0) return "none";
return tables.map((table) => `${table.table}(${table.rowCount})`).join(", ");
}
function summarizeSkippedTables(tables: SkippedTableSnapshot[]): string {
if (tables.length === 0) return "none";
return tables
.map((table) => `${table.table}(${table.rowCount}/${table.maxRows}: ${table.reason})`)
.join(", ");
}
function listProbeFailureBackups(sqliteFile: string): string[] {
const directory = path.dirname(sqliteFile);
const baseName = path.basename(sqliteFile);
const prefix = `${baseName}.probe-failed-`;
if (!fs.existsSync(directory)) return [];
return fs
.readdirSync(directory)
.filter((name) => name.startsWith(prefix))
.map((name) => ({
path: path.join(directory, name),
timestamp: Number(name.slice(prefix.length)),
}))
.sort((left, right) => {
const leftTimestamp = Number.isFinite(left.timestamp) ? left.timestamp : 0;
const rightTimestamp = Number.isFinite(right.timestamp) ? right.timestamp : 0;
return rightTimestamp - leftTimestamp || right.path.localeCompare(left.path);
})
.map((backup) => backup.path);
}
function captureCriticalDbState(sqliteFile: string): PreservedCriticalDbState {
const snapshot: PreservedCriticalDbState = {
captureSucceeded: false,
captureError: null,
preservedTables: [],
skippedTables: [],
};
if (!fs.existsSync(sqliteFile)) {
snapshot.captureSucceeded = true;
return snapshot;
}
let probe: SqliteDatabase | null = null;
try {
probe = openSqliteDatabase(sqliteFile, { readonly: true });
for (const tableSpec of CRITICAL_DB_TABLES) {
if (!hasTable(probe, tableSpec.table)) continue;
const maxRows = tableSpec.maxRows ?? DEFAULT_CRITICAL_TABLE_ROW_LIMIT;
const rows = (tableSpec.readRows?.(probe) ??
(probe
.prepare(`SELECT * FROM ${quoteIdentifier(tableSpec.table)}`)
.all() as JsonRecord[])) as JsonRecord[];
const rowCount = rows.length;
if (rowCount === 0) continue;
if (rowCount > maxRows) {
snapshot.skippedTables.push({
table: tableSpec.table,
rowCount,
maxRows,
reason: "row_limit_exceeded",
});
continue;
}
snapshot.preservedTables.push({
table: tableSpec.table,
rowCount,
maxRows,
columns: getTableColumns(probe, tableSpec.table),
rows,
});
}
snapshot.captureSucceeded = true;
return snapshot;
} catch (error: unknown) {
snapshot.captureError = error instanceof Error ? error.message : String(error);
return snapshot;
} finally {
try {
probe?.close();
} catch {
/* ignore */
}
}
}
function restoreCriticalDbState(
db: SqliteDatabase,
snapshot: PreservedCriticalDbState
): PreservedTableSnapshot[] {
const restoredTables: PreservedTableSnapshot[] = [];
const restore = db.transaction(() => {
for (const table of snapshot.preservedTables) {
if (table.rows.length === 0) continue;
if (!hasTable(db, table.table)) {
throw new Error(`Current schema is missing preserved table "${table.table}"`);
}
const currentColumns = new Set(getTableColumns(db, table.table));
const restoreColumns = table.columns.filter((column) => currentColumns.has(column));
if (restoreColumns.length === 0) {
throw new Error(`No compatible columns remain for preserved table "${table.table}"`);
}
const sql = `INSERT OR REPLACE INTO ${quoteIdentifier(table.table)} (${restoreColumns
.map((column) => quoteIdentifier(column))
.join(", ")}) VALUES (${restoreColumns.map(() => "?").join(", ")})`;
const insert = db.prepare(sql);
for (const row of table.rows) {
insert.run(...restoreColumns.map((column) => row[column] ?? null));
}
restoredTables.push(table);
}
});
restore();
return restoredTables;
}
function cleanupRecreatedSqliteFiles(sqliteFile: string) {
for (const filePath of [
sqliteFile,
`${sqliteFile}-wal`,
`${sqliteFile}-shm`,
`${sqliteFile}-journal`,
]) {
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {
/* ignore */
}
}
}
function parseLegacyError(value: unknown): unknown {
if (typeof value !== "string" || value.trim().length === 0) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function offloadLegacyCallLogDetails(db: SqliteDatabase) {
if (!hasTable(db, "call_logs_v1_legacy")) return;
type LegacyCallLogRow = {
id: string;
timestamp: string | null;
method: string | null;
path: string | null;
status: number | null;
model: string | null;
requested_model: string | null;
provider: string | null;
account: string | null;
connection_id: string | null;
duration: number | null;
tokens_in: number | null;
tokens_out: number | null;
tokens_cache_read: number | null;
tokens_cache_creation: number | null;
tokens_reasoning: number | null;
tokens_compressed: number | null;
request_type: string | null;
source_format: string | null;
target_format: string | null;
api_key_id: string | null;
api_key_name: string | null;
combo_name: string | null;
combo_step_id: string | null;
combo_execution_key: string | null;
request_body: string | null;
response_body: string | null;
error: string | null;
};
const pendingRows = db
.prepare(
`
SELECT legacy.*
FROM call_logs_v1_legacy AS legacy
JOIN call_logs AS current ON current.id = legacy.id
WHERE current.detail_state = 'legacy-inline'
ORDER BY legacy.timestamp ASC
`
)
.all() as LegacyCallLogRow[];
if (pendingRows.length === 0) {
db.exec("DROP TABLE IF EXISTS call_logs_v1_legacy");
return;
}
const updateStmt = db.prepare(`
UPDATE call_logs
SET artifact_relpath = @artifactRelPath,
artifact_size_bytes = @artifactSizeBytes,
artifact_sha256 = @artifactSha256,
detail_state = 'ready'
WHERE id = @id
`);
const markMissingStmt = db.prepare(`
UPDATE call_logs
SET detail_state = 'missing',
artifact_relpath = NULL,
artifact_size_bytes = NULL,
artifact_sha256 = NULL
WHERE id = ?
`);
let failed = 0;
const tx = db.transaction(() => {
for (const row of pendingRows) {
const artifact: CallLogArtifact = {
schemaVersion: 5,
summary: {
id: row.id,
timestamp: row.timestamp || new Date().toISOString(),
method: row.method || "POST",
path: row.path || "/v1/chat/completions",
status: row.status || 0,
model: row.model || "-",
requestedModel: row.requested_model || null,
provider: row.provider || "-",
account: row.account || "-",
connectionId: row.connection_id || null,
duration: row.duration || 0,
tokens: {
in: row.tokens_in || 0,
out: row.tokens_out || 0,
cacheRead: row.tokens_cache_read ?? null,
cacheWrite: row.tokens_cache_creation ?? null,
reasoning: row.tokens_reasoning ?? null,
compressed: row.tokens_compressed ?? null,
},
requestType: row.request_type || null,
sourceFormat: row.source_format || null,
targetFormat: row.target_format || null,
apiKeyId: row.api_key_id || null,
apiKeyName: row.api_key_name || null,
comboName: row.combo_name || null,
comboStepId: row.combo_step_id || null,
comboExecutionKey: row.combo_execution_key || null,
},
requestBody: parseStoredPayload(row.request_body),
responseBody: parseStoredPayload(row.response_body),
error: parseLegacyError(row.error),
};
const artifactResult = writeCallArtifact(
artifact,
buildArtifactRelativePath(artifact.summary.timestamp, artifact.summary.id)
);
if (!artifactResult) {
failed++;
markMissingStmt.run(row.id);
continue;
}
updateStmt.run({
id: row.id,
artifactRelPath: artifactResult.relPath,
artifactSizeBytes: artifactResult.sizeBytes,
artifactSha256: artifactResult.sha256,
});
}
});
tx();
if (failed > 0) {
console.warn(
`[DB] Kept call_logs_v1_legacy after partial call log offload (${failed} failed row(s)).`
);
return;
}
db.exec("DROP TABLE IF EXISTS call_logs_v1_legacy");
try {
db.pragma("wal_checkpoint(TRUNCATE)");
db.exec("VACUUM");
console.log(`[DB] Offloaded ${pendingRows.length} legacy call log detail row(s) to artifacts.`);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn("[DB] Legacy call log compaction finished without VACUUM:", message);
}
}
function isAutomatedTestProcess(): boolean {
return (
typeof process !== "undefined" &&
(process.env.NODE_ENV === "test" ||
process.env.VITEST !== undefined ||
process.argv.some((arg) => arg.includes("test")))
);
}
function shouldRunStartupDbHealthCheck(): boolean {
if (process.env.OMNIROUTE_FORCE_DB_HEALTHCHECK === "1") return true;
return !isAutomatedTestProcess();
}
function createManagedDbBackup(db: SqliteDatabase, reason: string): boolean {
const isTest = isAutomatedTestProcess();
if (isTest) return false;
try {
const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups");
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = path.join(backupDir, `db_${timestamp}_${reason}.sqlite`);
const escapedBackupPath = backupPath.replace(/'/g, "''");
db.exec(`VACUUM INTO '${escapedBackupPath}'`);
console.log(`[DB] Backup created (${reason}): ${backupPath}`);
return true;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[DB] Failed to create ${reason} backup:`, message);
return false;
}
}
function createHealthCheckBackup(db: SqliteDatabase): boolean {
return createManagedDbBackup(db, "health-check-repair");
}
function autoMigrateLegacyEncryptedConnections(db: SqliteDatabase): number {
const rows = db.prepare("SELECT * FROM provider_connections").all() as JsonRecord[];
const updateStmt = db.prepare(
"UPDATE provider_connections SET api_key = @apiKey, id_token = @idToken, access_token = @accessToken, refresh_token = @refreshToken, updated_at = @updatedAt WHERE id = @id"
);
const encryptedFields = ["apiKey", "idToken", "accessToken", "refreshToken"] as const;
let migratedCount = 0;
let backupCreated = false;
for (const row of rows) {
const camelRow = rowToCamel(row);
if (!camelRow) continue;
let updatedRow = false;
for (const field of encryptedFields) {
if (typeof camelRow[field] !== "string") continue;
const { updated, value } = migrateLegacyEncryptedString(camelRow[field]);
if (updated) {
camelRow[field] = value;
updatedRow = true;
}
}
if (!updatedRow) continue;
if (!backupCreated) {
createManagedDbBackup(db, "legacy-encryption-migration");
backupCreated = true;
}
updateStmt.run({
id: camelRow.id,
apiKey: camelRow.apiKey ?? null,
idToken: camelRow.idToken ?? null,
accessToken: camelRow.accessToken ?? null,
refreshToken: camelRow.refreshToken ?? null,
updatedAt: new Date().toISOString(),
});
migratedCount++;
}
if (migratedCount > 0) {
invalidateDbCache("connections");
console.log(`[DB] Auto-migrated ${migratedCount} connection(s) to new static-salt encryption.`);
}
return migratedCount;
}
let dbHealthCheckTimer: NodeJS.Timeout | null = null;
function getDbHealthCheckIntervalMs(): number {
const rawValue = process.env.OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS;
if (typeof rawValue === "string" && rawValue.trim().length > 0) {
const parsed = Number(rawValue);
if (Number.isFinite(parsed) && parsed >= 0) {
return parsed;
}
}
return 6 * 60 * 60 * 1000;
}
function clearDbHealthCheckScheduler() {
if (dbHealthCheckTimer) {
clearInterval(dbHealthCheckTimer);
dbHealthCheckTimer = null;
}
}
function startDbHealthCheckScheduler(db: SqliteDatabase) {
clearDbHealthCheckScheduler();
if (isCloud || isBuildPhase || isAutomatedTestProcess()) return;
const intervalMs = getDbHealthCheckIntervalMs();
if (intervalMs <= 0) return;
dbHealthCheckTimer = setInterval(() => {
try {
if (!db.open) return;
runDbHealthCheck(db, {
autoRepair: true,
expectedSchemaVersion: "1",
createBackupBeforeRepair: () => createHealthCheckBackup(db),
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn("[DB] Periodic health-check failed:", message);
}
}, intervalMs);
dbHealthCheckTimer.unref?.();
}
export function runManagedDbHealthCheck(options?: { autoRepair?: boolean }) {
const db = getDbInstance();
return runDbHealthCheck(db, {
autoRepair: options?.autoRepair === true,
expectedSchemaVersion: "1",
createBackupBeforeRepair: () => createHealthCheckBackup(db),
});
}
export function getDbInstance(): SqliteDatabase {
const existing = getDb();
if (existing) return existing;
if (isCloud || isBuildPhase) {
if (isBuildPhase) {
console.log("[DB] Build phase detected — using in-memory SQLite (read-only)");
}
const memoryDb = openSqliteDatabase(":memory:");
memoryDb.pragma("journal_mode = WAL");
memoryDb.exec(SCHEMA_SQL);
ensureUsageHistoryColumns(memoryDb);
ensureCallLogsColumns(memoryDb);
ensureProviderConnectionsColumns(memoryDb);
setDb(memoryDb);
return memoryDb;
}
const sqliteFile = SQLITE_FILE;
if (!sqliteFile) {
throw new Error("SQLITE_FILE is unavailable for local mode");
}
const jsonDbFile = JSON_DB_FILE;
const probeFailureBackups = listProbeFailureBackups(sqliteFile);
if (!fs.existsSync(sqliteFile) && probeFailureBackups.length > 0) {
const latestBackup = probeFailureBackups[0];
try {
fs.renameSync(latestBackup, sqliteFile);
console.log(
`[DB] Auto-restored preserved database from previous probe failure: ${path.basename(latestBackup)}`
);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
throw new Error(
`[DB] Manual recovery required before startup. ` +
`Failed to auto-restore preserved database ${latestBackup}: ${msg}. ` +
`Restore the preserved file or another backup to ${sqliteFile} before restarting.`
);
}
}
let preservedCriticalState: PreservedCriticalDbState = {
captureSucceeded: true,
captureError: null,
preservedTables: [],
skippedTables: [],
};
let failedProbePath: string | null = null;
let failedProbeMessage: string | null = null;
if (fs.existsSync(sqliteFile)) {
preservedCriticalState = captureCriticalDbState(sqliteFile);
if (preservedCriticalState.captureSucceeded) {
if (preservedCriticalState.preservedTables.length > 0) {
console.log(
`[DB] Preserved critical DB state before potential recreation: ${summarizePreservedTables(
preservedCriticalState.preservedTables
)}`
);
}
if (preservedCriticalState.skippedTables.length > 0) {
console.warn(
`[DB] Critical DB tables skipped during preservation: ${summarizeSkippedTables(
preservedCriticalState.skippedTables
)}`
);
}
} else if (preservedCriticalState.captureError) {
console.warn(
`[DB] Could not preserve critical DB state before recreation: ${preservedCriticalState.captureError}`
);
}
}
// Track whether the DB file is brand new (fresh DATA_DIR / Docker volume).
// This is needed so the migration runner skips the mass-migration safety abort
// that would otherwise trigger because heuristic seeding marks some migrations
// as applied, making the fresh DB look like a wiped existing DB (#1328).
const isNewDb = !fs.existsSync(sqliteFile);
// Detect and handle old schema format — preserve data when possible (#146)
// Uses a single probe connection that becomes the real connection when possible.
if (fs.existsSync(sqliteFile)) {
try {
const probe = openSqliteDatabase(sqliteFile, { readonly: true });
const hasOldSchema = probe
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'")
.get();
if (hasOldSchema) {
let hasData = false;
try {
const count = probe.prepare("SELECT COUNT(*) as c FROM provider_connections").get() as
| { c: number }
| undefined;
hasData = Boolean(count && count.c > 0);
} catch {
// Table might not exist at all — truly incompatible
}
probe.close();
if (hasData) {
console.log(
`[DB] Old schema_migrations table found but data exists — preserving data (#146)`
);
const fixDb = openSqliteDatabase(sqliteFile);
try {
fixDb.exec("DROP TABLE IF EXISTS schema_migrations");
fixDb.pragma("wal_checkpoint(TRUNCATE)");
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
console.warn("[DB] Could not clean up old schema table:", message);
} finally {
fixDb.close();
}
} else {
const oldPath = sqliteFile + ".old-schema";
console.log(
`[DB] Old incompatible schema detected (empty) — renaming to ${path.basename(oldPath)}`
);
fs.renameSync(sqliteFile, oldPath);
for (const ext of ["-wal", "-shm"]) {
try {
if (fs.existsSync(sqliteFile + ext)) fs.unlinkSync(sqliteFile + ext);
} catch {
/* ok */
}
}
}
} else {
probe.close();
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
console.warn("[DB] Could not probe existing DB:", message);
// If the error is a Node module/ABI failure, throw it immediately to avoid renaming the database
if (isNativeSqliteLoadError(e) || message.includes("could not be found")) {
throw e;
}
// SAFETY: Never delete the database — rename to backup so data can be recovered.
// The old code would silently destroy all user data on any probe failure.
const failedPath = sqliteFile + `.probe-failed-${Date.now()}`;
try {
fs.renameSync(sqliteFile, failedPath);
console.warn(`[DB] Renamed corrupt DB to ${path.basename(failedPath)}`);
failedProbePath = failedPath;
failedProbeMessage = message;
} catch {
/* ok */
}
}
}
if (failedProbePath) {
const hasUnsafeSkippedTables = preservedCriticalState.skippedTables.length > 0;
const missingSnapshot = !preservedCriticalState.captureSucceeded;
if (hasUnsafeSkippedTables || missingSnapshot) {
const details = missingSnapshot
? `snapshot_failed=${preservedCriticalState.captureError || "unknown"}`
: `skipped_tables=${summarizeSkippedTables(preservedCriticalState.skippedTables)}`;
throw new Error(
`[DB] Manual recovery required after probe failure. ` +
`Preserved database: ${failedProbePath}. ` +
`Automatic recovery was aborted because ${details}. ` +
`Original probe error: ${failedProbeMessage || "unknown"}.`
);
}
}
const db = openSqliteDatabase(sqliteFile);
db.pragma("journal_mode = WAL");
db.pragma("busy_timeout = 5000");
db.pragma("synchronous = NORMAL");
db.exec(SCHEMA_SQL);
ensureProviderConnectionsColumns(db);
ensureUsageHistoryColumns(db);
ensureCallLogsColumns(db);
// ── Versioned Migrations ──
// Auto-seed 001 as applied (the inline SCHEMA_SQL already created these tables)
// then run any new migrations (002+)
db.exec(`
CREATE TABLE IF NOT EXISTS _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO _omniroute_migrations (version, name)
VALUES ('001', 'initial_schema');
`);
runMigrations(db, { isNewDb });
offloadLegacyCallLogDetails(db);
// Auto-migrate from db.json if exists
if (jsonDbFile && fs.existsSync(jsonDbFile)) {
migrateFromJson(db, jsonDbFile);
}
if (failedProbePath && preservedCriticalState.preservedTables.length > 0) {
try {
const restoredTables = restoreCriticalDbState(db, preservedCriticalState);
console.log(
`[DB] Restored preserved critical DB state after probe failure: ${summarizePreservedTables(
restoredTables
)}`
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
try {
if (db.open) db.close();
} catch {
/* ignore */
}
cleanupRecreatedSqliteFiles(sqliteFile);
throw new Error(
`[DB] Automatic recovery aborted after probe failure. ` +
`Preserved database: ${failedProbePath}. ` +
`Restore failure: ${message}.`
);
}
}
// Store schema version
const versionStmt = db.prepare(
"INSERT OR REPLACE INTO db_meta (key, value) VALUES ('schema_version', '1')"
);
versionStmt.run();
if (shouldRunStartupDbHealthCheck()) {
runDbHealthCheck(db, {
autoRepair: true,
expectedSchemaVersion: "1",
createBackupBeforeRepair: () => createHealthCheckBackup(db),
});
}
setDb(db);
// Re-encrypt any tokens using the legacy dynamic salt to canonical static salt
try {
autoMigrateLegacyEncryptedConnections(db);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[DB] Legacy encryption migration failed: ${message}`);
}
startDbHealthCheckScheduler(db);
console.log(`[DB] SQLite database ready: ${sqliteFile}`);
return db;
}
export function closeDbInstance(options?: { checkpointMode?: CheckpointMode | null }): boolean {
clearDbHealthCheckScheduler();
const db = getDb();
if (!db) return false;
const checkpointMode = options?.checkpointMode ?? "TRUNCATE";
try {
if (checkpointMode) {
try {
if (checkpointDb(db, checkpointMode)) {
console.log(`[DB] SQLite WAL checkpoint completed (${checkpointMode}).`);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[DB] WAL checkpoint failed during close (${checkpointMode}):`, message);
}
}
} finally {
try {
if (db.open) db.close();
} finally {
setDb(null);
}
}
return true;
}
/**
* Reset the singleton (used by restore).
*/
export function resetDbInstance() {
closeDbInstance();
}
// ──────────────── Runtime Driver Info ────────────────
type DbDriverInfo = { source: string; kind: string };
let driverInfoCached: DbDriverInfo | null = null;
function setDriverInfo(info: DbDriverInfo) {
driverInfoCached = info;
}
/** Returns how better-sqlite3 was resolved (bundled / runtime / etc.). Null if not yet init. */
export function getDriverInfo(): DbDriverInfo | null {
return driverInfoCached;
}
/**
* Async initializer that pre-resolves the SQLite runtime before first DB access.
*
* Call this at process startup (before any call to getDbInstance()) so that
* if the bundled better-sqlite3 binary is unavailable, the runtime installer
* can place it in ~/.omniroute/runtime/ without blocking a synchronous caller.
*
* Idempotent — safe to call multiple times.
*/
export async function ensureDbInitialized(): Promise<void> {
if (getDb()) return;
try {
const runtimeModule = await import("../../../bin/cli/runtime/sqliteRuntime.mjs" as any);
const { driver, source } = await runtimeModule.loadSqliteRuntime();
setDriverInfo({ source, kind: driver.kind as string });
if ((driver.kind as string) !== "better-sqlite3") {
console.warn(
`[DB] better-sqlite3 unavailable (resolved via ${source}/${driver.kind}). ` +
`OmniRoute may fall back to read-only or limited functionality.`
);
}
} catch {
// Runtime loader unavailable (CLI context only) — DB init falls through to normal path.
}
getDbInstance();
}
// ──────────────── JSON → SQLite Migration ────────────────
function migrateFromJson(db: SqliteDatabase, jsonPath: string) {
try {
const raw = fs.readFileSync(jsonPath, "utf-8");
const data = JSON.parse(raw);
const connCount = (data.providerConnections || []).length;
const nodeCount = (data.providerNodes || []).length;
const keyCount = (data.apiKeys || []).length;
if (connCount === 0 && nodeCount === 0 && keyCount === 0) {
console.log("[DB] db.json has no data to migrate, skipping");
fs.renameSync(jsonPath, jsonPath + ".empty");
return;
}
console.log(
`[DB] Migrating db.json → SQLite (${connCount} connections, ${nodeCount} nodes, ${keyCount} keys)...`
);
const migrate = db.transaction(() => {
// 1. Provider Connections
const insertConn = db.prepare(`
INSERT OR REPLACE INTO provider_connections (
id, provider, auth_type, name, email, priority, is_active,
access_token, refresh_token, expires_at, token_expires_at,
scope, project_id, test_status, error_code, last_error,
last_error_at, last_error_type, last_error_source, backoff_level,
rate_limited_until, health_check_interval, last_health_check_at,
last_tested, api_key, id_token, provider_specific_data,
expires_in, display_name, global_priority, default_model,
token_type, consecutive_use_count, rate_limit_protection, last_used_at, created_at, updated_at
) VALUES (
@id, @provider, @authType, @name, @email, @priority, @isActive,
@accessToken, @refreshToken, @expiresAt, @tokenExpiresAt,
@scope, @projectId, @testStatus, @errorCode, @lastError,
@lastErrorAt, @lastErrorType, @lastErrorSource, @backoffLevel,
@rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt,
@lastTested, @apiKey, @idToken, @providerSpecificData,
@expiresIn, @displayName, @globalPriority, @defaultModel,
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @createdAt, @updatedAt
)
`);
for (const conn of data.providerConnections || []) {
insertConn.run({
id: conn.id,
provider: conn.provider,
authType: conn.authType || "oauth",
name: conn.name || null,
email: conn.email || null,
priority: conn.priority || 0,
isActive: conn.isActive === false ? 0 : 1,
accessToken: conn.accessToken || null,
refreshToken: conn.refreshToken || null,
expiresAt: conn.expiresAt || null,
tokenExpiresAt: conn.tokenExpiresAt || null,
scope: conn.scope || null,
projectId: conn.projectId || null,
testStatus: conn.testStatus || null,
errorCode: conn.errorCode || null,
lastError: conn.lastError || null,
lastErrorAt: conn.lastErrorAt || null,
lastErrorType: conn.lastErrorType || null,
lastErrorSource: conn.lastErrorSource || null,
backoffLevel: conn.backoffLevel || 0,
rateLimitedUntil: conn.rateLimitedUntil || null,
healthCheckInterval: conn.healthCheckInterval || null,
lastHealthCheckAt: conn.lastHealthCheckAt || null,
lastTested: conn.lastTested || null,
apiKey: conn.apiKey || null,
idToken: conn.idToken || null,
providerSpecificData: conn.providerSpecificData
? JSON.stringify(conn.providerSpecificData)
: null,
expiresIn: conn.expiresIn || null,
displayName: conn.displayName || null,
globalPriority: conn.globalPriority || null,
defaultModel: conn.defaultModel || null,
tokenType: conn.tokenType || null,
consecutiveUseCount: conn.consecutiveUseCount || 0,
lastUsedAt: conn.lastUsedAt || null,
rateLimitProtection:
conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0,
createdAt: conn.createdAt || new Date().toISOString(),
updatedAt: conn.updatedAt || new Date().toISOString(),
});
}
// 2. Provider Nodes
const insertNode = db.prepare(`
INSERT OR REPLACE INTO provider_nodes (id, type, name, prefix, api_type, base_url, created_at, updated_at)
VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @createdAt, @updatedAt)
`);
for (const node of data.providerNodes || []) {
insertNode.run({
id: node.id,
type: node.type,
name: node.name,
prefix: node.prefix || null,
apiType: node.apiType || null,
baseUrl: node.baseUrl || null,
createdAt: node.createdAt || new Date().toISOString(),
updatedAt: node.updatedAt || new Date().toISOString(),
});
}
// 3. Key-Value pairs
const insertKv = db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
);
for (const [alias, model] of Object.entries(data.modelAliases || {})) {
insertKv.run("modelAliases", alias, JSON.stringify(model));
}
for (const [toolName, mappings] of Object.entries(data.mitmAlias || {})) {
insertKv.run("mitmAlias", toolName, JSON.stringify(mappings));
}
for (const [key, value] of Object.entries(data.settings || {})) {
insertKv.run("settings", key, JSON.stringify(value));
}
for (const [provider, models] of Object.entries(data.pricing || {})) {
insertKv.run("pricing", provider, JSON.stringify(models));
}
for (const [providerId, models] of Object.entries(data.customModels || {})) {
insertKv.run("customModels", providerId, JSON.stringify(models));
}
if (data.proxyConfig) {
insertKv.run("proxyConfig", "global", JSON.stringify(data.proxyConfig.global || null));
insertKv.run("proxyConfig", "providers", JSON.stringify(data.proxyConfig.providers || {}));
insertKv.run("proxyConfig", "combos", JSON.stringify(data.proxyConfig.combos || {}));
insertKv.run("proxyConfig", "keys", JSON.stringify(data.proxyConfig.keys || {}));
}
// 4. Combos
const insertCombo = db.prepare(`
INSERT OR REPLACE INTO combos (id, name, data, sort_order, created_at, updated_at)
VALUES (@id, @name, @data, @sortOrder, @createdAt, @updatedAt)
`);
for (const [index, combo] of (data.combos || []).entries()) {
const normalizedCombo = {
...combo,
sortOrder: typeof combo.sortOrder === "number" ? combo.sortOrder : index + 1,
};
insertCombo.run({
id: normalizedCombo.id,
name: normalizedCombo.name,
data: JSON.stringify(normalizedCombo),
sortOrder: normalizedCombo.sortOrder,
createdAt: normalizedCombo.createdAt || new Date().toISOString(),
updatedAt: normalizedCombo.updatedAt || new Date().toISOString(),
});
}
// 5. API Keys
const insertKey = db.prepare(`
INSERT OR REPLACE INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at)
VALUES (@id, @name, @key, @machineId, @allowedModels, @noLog, @createdAt)
`);
for (const apiKey of data.apiKeys || []) {
insertKey.run({
id: apiKey.id,
name: apiKey.name,
key: apiKey.key,
machineId: apiKey.machineId || null,
allowedModels: JSON.stringify(apiKey.allowedModels || []),
noLog: apiKey.noLog ? 1 : 0,
createdAt: apiKey.createdAt || new Date().toISOString(),
});
}
});
migrate();
const migratedPath = jsonPath + ".migrated";
fs.renameSync(jsonPath, migratedPath);
console.log(`[DB] ✓ Migration complete. Original saved as ${migratedPath}`);
const legacyBackupDir = path.join(DATA_DIR, "db_backups");
if (fs.existsSync(legacyBackupDir)) {
const jsonBackups = fs.readdirSync(legacyBackupDir).filter((f) => f.endsWith(".json"));
if (jsonBackups.length > 0) {
console.log(
`[DB] Note: ${jsonBackups.length} legacy .json backups remain in ${legacyBackupDir}`
);
}
}
} catch (err) {
console.error("[DB] Migration from db.json failed:", err.message);
}
}
// ──────────────── Auto-Vacuum Management ────────────────
export function setAutoVacuum(mode: "NONE" | "FULL" | "INCREMENTAL"): void {
const db = getDbInstance();
const currentMode = db.pragma("auto_vacuum", { simple: true }) as number;
const modeMap: Record<string, number> = {
NONE: 0,
FULL: 1,
INCREMENTAL: 2,
};
const targetMode = modeMap[mode];
if (currentMode === targetMode) {
console.log(`[DB] auto_vacuum already set to ${mode}`);
return;
}
console.log(`[DB] Changing auto_vacuum from ${currentMode} to ${mode} (${targetMode})`);
db.pragma(`auto_vacuum = ${targetMode}`);
db.exec("VACUUM");
const newMode = db.pragma("auto_vacuum", { simple: true }) as number;
console.log(`[DB] auto_vacuum changed to ${newMode}`);
}
export function getAutoVacuumMode(): "NONE" | "FULL" | "INCREMENTAL" {
const db = getDbInstance();
const mode = db.pragma("auto_vacuum", { simple: true }) as number;
const modeMap: Record<number, "NONE" | "FULL" | "INCREMENTAL"> = {
0: "NONE",
1: "FULL",
2: "INCREMENTAL",
};
return modeMap[mode] || "NONE";
}
export function runManualVacuum(): { success: boolean; duration: number; error?: string } {
const db = getDbInstance();
const startTime = Date.now();
try {
console.log("[DB] Starting manual VACUUM...");
db.exec("VACUUM");
const duration = Date.now() - startTime;
console.log(`[DB] Manual VACUUM completed in ${duration}ms`);
return { success: true, duration };
} catch (err: unknown) {
const duration = Date.now() - startTime;
console.error("[DB] Manual VACUUM failed:", err);
const message = err instanceof Error ? err.message : String(err);
return { success: false, duration, error: message };
}
}
export function setPageSize(pageSize: number): void {
const db = getDbInstance();
const currentPageSize = db.pragma("page_size", { simple: true }) as number;
if (currentPageSize === pageSize) {
console.log(`[DB] page_size already set to ${pageSize}`);
return;
}
console.log(`[DB] Changing page_size from ${currentPageSize} to ${pageSize}`);
db.pragma(`page_size = ${pageSize}`);
db.exec("VACUUM");
const newPageSize = db.pragma("page_size", { simple: true }) as number;
console.log(`[DB] page_size changed to ${newPageSize}`);
}
export function setCacheSize(cacheSizeKb: number): void {
const db = getDbInstance();
const currentCacheSize = db.pragma("cache_size", { simple: true }) as number;
const targetCacheSize = -cacheSizeKb;
if (currentCacheSize === targetCacheSize) {
console.log(`[DB] cache_size already set to ${cacheSizeKb}KB`);
return;
}
console.log(`[DB] Changing cache_size from ${Math.abs(currentCacheSize)}KB to ${cacheSizeKb}KB`);
db.pragma(`cache_size = ${targetCacheSize}`);
const newCacheSize = db.pragma("cache_size", { simple: true }) as number;
console.log(`[DB] cache_size changed to ${Math.abs(newCacheSize)}KB`);
}