diff --git a/src/app/api/db-backups/import/route.ts b/src/app/api/db-backups/import/route.ts index c594d86cde..f25a3285da 100644 --- a/src/app/api/db-backups/import/route.ts +++ b/src/app/api/db-backups/import/route.ts @@ -1,9 +1,10 @@ import { NextResponse } from "next/server"; -import Database from "better-sqlite3"; import path from "path"; import fs from "fs"; import os from "os"; import { getDbInstance, resetDbInstance, SQLITE_FILE } from "@/lib/db/core"; +import { openDatabaseAsync } from "@/lib/db/adapters/driverFactory"; +import type { SqliteAdapter } from "@/lib/db/adapters/types"; import { backupDbFile } from "@/lib/db/backup"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { getSettings } from "@/lib/db/settings"; @@ -86,10 +87,14 @@ export async function POST(request: Request) { tmpPath = path.join(os.tmpdir(), `omniroute-import-${Date.now()}.sqlite`); fs.writeFileSync(tmpPath, fileBuffer!); - // Validate SQLite integrity - let testDb: InstanceType | null = null; + // Validate SQLite integrity. + // Use the resilient driver factory (better-sqlite3 → node:sqlite → sql.js) rather than + // a direct `better-sqlite3` import: in the packaged Electron app that native module is + // absent from the standalone server's node_modules, so a hard import crashes the route + // with "Cannot find module 'better-sqlite3'" even though node:sqlite is available (#3025). + let testDb: SqliteAdapter | null = null; try { - testDb = new Database(tmpPath, { readonly: true }); + testDb = await openDatabaseAsync(tmpPath, { readonly: true }); const result = testDb.pragma("integrity_check") as any[]; if (result[0]?.integrity_check !== "ok") { return NextResponse.json( diff --git a/tests/unit/db-import-resilient-driver-3025.test.ts b/tests/unit/db-import-resilient-driver-3025.test.ts new file mode 100644 index 0000000000..8a54c94540 --- /dev/null +++ b/tests/unit/db-import-resilient-driver-3025.test.ts @@ -0,0 +1,73 @@ +/** + * #3025 — DB import route must not hard-depend on a static `better-sqlite3` import. + * + * In the packaged Electron app, `better-sqlite3` is stripped from the Next standalone + * server's `node_modules` (it is rebuilt for the Electron ABI elsewhere). Every other DB + * code path survives because it loads the driver through the resilient driver factory + * (`tryOpenSync` → better-sqlite3 → node:sqlite → sql.js). The db-backups *import* route + * was the lone exception: it did `import Database from "better-sqlite3"` at module scope, + * so loading the route crashed with `Cannot find module 'better-sqlite3'` (reported on + * Windows installer v3.8.10), even though node:sqlite was available. + * + * Guard: no API route under src/app/api may statically import/require better-sqlite3 — + * routes run inside the standalone server where the native module is not guaranteed. + * Behaviour: the resilient opener validates a real sqlite file via PRAGMA integrity_check + * and surfaces the same `[{ integrity_check: "ok" }]` shape the route relies on. + */ +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"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { openDatabaseAsync } from "../../src/lib/db/adapters/driverFactory.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const API_DIR = path.join(__dirname, "..", "..", "src", "app", "api"); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (/\.(ts|tsx|js|mjs)$/.test(entry)) out.push(full); + } + return out; +} + +const DIRECT_IMPORT = /(?:from\s+|require\(\s*)["']better-sqlite3["']/; + +test("no API route statically imports better-sqlite3 (must use the resilient driver factory)", () => { + const offenders = walk(API_DIR).filter((file) => DIRECT_IMPORT.test(readFileSync(file, "utf8"))); + assert.deepEqual( + offenders.map((f) => path.relative(API_DIR, f)), + [], + "API routes run in the standalone server where better-sqlite3 may be absent; open " + + "databases via src/lib/db (openDatabaseAsync / getDbInstance), never a direct import." + ); +}); + +test("openDatabaseAsync validates a real sqlite file with the integrity_check shape the route expects", async () => { + const tmp = path.join(os.tmpdir(), `omniroute-3025-${process.pid}-${process.hrtime.bigint()}.sqlite`); + // Seed a valid sqlite file through the same resilient adapter the route will now use. + const seed = await openDatabaseAsync(tmp); + seed.exec("CREATE TABLE api_keys (id INTEGER PRIMARY KEY)"); + seed.close(); + + const db = await openDatabaseAsync(tmp, { readonly: true }); + try { + const result = db.pragma("integrity_check") as Array<{ integrity_check?: string }>; + assert.equal(result[0]?.integrity_check, "ok"); + const tables = (db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ + name: string; + }>).map((r) => r.name); + assert.ok(tables.includes("api_keys")); + } finally { + db.close(); + for (const f of [tmp, `${tmp}-wal`, `${tmp}-shm`, `${tmp}-journal`]) { + if (fs.existsSync(f)) fs.unlinkSync(f); + } + } +});