diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index f61c7041cab..27faa6c5cfe 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -214,6 +214,11 @@ const EXTRA_MODULE_ENTRIES = [ src: ["node_modules", "undici"], dest: ["node_modules", "undici"], }, + { + label: "sql.js WASM fallback runtime", + src: ["node_modules", "sql.js"], + dest: ["node_modules", "sql.js"], + }, { label: "sqlite-vec wrapper (vector memory - loaded at runtime via createRequire)", src: ["node_modules", "sqlite-vec"], diff --git a/scripts/check/check-pack-boot.mjs b/scripts/check/check-pack-boot.mjs index 62a4b78ab5e..9eabab477ac 100644 --- a/scripts/check/check-pack-boot.mjs +++ b/scripts/check/check-pack-boot.mjs @@ -20,6 +20,13 @@ import path from "node:path"; const POLL_INTERVAL_MS = 2_000; const BOOT_DEADLINE_MS = 240_000; +const SQLJS_STARTUP_MARKER = "Pre-initializing sql.js WASM"; + +export const REQUIRED_SQLJS_RUNTIME_FILES = Object.freeze([ + "dist/node_modules/sql.js/package.json", + "dist/node_modules/sql.js/dist/sql-wasm.js", + "dist/node_modules/sql.js/dist/sql-wasm.wasm", +]); /** Parse `npm pack --json` output into the generated tarball filename. */ export function pickTarball(packJsonOutput) { @@ -49,20 +56,278 @@ export function pickPort(seed = process.pid) { return 23000 + (seed % 4000); } +export function findMissingSqlJsRuntimeFiles(packageRoot, exists = fs.existsSync) { + return REQUIRED_SQLJS_RUNTIME_FILES.filter( + (relativePath) => !exists(path.join(packageRoot, relativePath)) + ); +} + +export function evaluateSqlJsRoundTrip({ + startupOutput, + beforeValue, + patchedValue, + readBackValue, +}) { + const failures = []; + if (!startupOutput.includes(SQLJS_STARTUP_MARKER)) { + failures.push("server output did not confirm the forced sql.js startup path"); + } + if (patchedValue !== !beforeValue) { + failures.push( + `PATCH debugMode returned ${String(patchedValue)} (expected ${String(!beforeValue)})` + ); + } + if (readBackValue !== !beforeValue) { + failures.push( + `GET debugMode returned ${String(readBackValue)} (expected ${String(!beforeValue)})` + ); + } + return { ok: failures.length === 0, failures }; +} + +/** + * After a clean shutdown + restart with the same DATA_DIR, the value written in boot #1 + * must be read back from disk in boot #2. sql.js is in-memory with debounced/flush writes, + * so this proves the persisted file actually landed and the restart reads it. + */ +export function evaluateRestartPersistence({ expectedValue, restartValue }) { + const failures = []; + if (restartValue !== expectedValue) { + failures.push( + `restart GET debugMode returned ${String(restartValue)} (expected ${String(expectedValue)} after restart)` + ); + } + return { ok: failures.length === 0, failures }; +} + +async function readJsonResponse(url, options) { + const response = await fetch(url, options); + const body = await response.json().catch(() => null); + return { response, body }; +} + +async function verifySettingsRoundTrip(baseUrl, startupOutput) { + const initial = await readJsonResponse(`${baseUrl}/api/settings`); + if (initial.response.status !== 200 || !initial.body || typeof initial.body !== "object") { + return { + ok: false, + failures: [`initial settings HTTP ${initial.response.status} or non-JSON body`], + }; + } + + const beforeValue = initial.body.debugMode === true; + const expectedValue = !beforeValue; + const patched = await readJsonResponse(`${baseUrl}/api/settings`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ debugMode: expectedValue }), + }); + if (patched.response.status !== 200 || !patched.body || typeof patched.body !== "object") { + return { + ok: false, + failures: [`settings PATCH HTTP ${patched.response.status} or non-JSON body`], + }; + } + + const readBack = await readJsonResponse(`${baseUrl}/api/settings`); + if (readBack.response.status !== 200 || !readBack.body || typeof readBack.body !== "object") { + return { + ok: false, + failures: [`settings read-back HTTP ${readBack.response.status} or non-JSON body`], + }; + } + + return { + ...evaluateSqlJsRoundTrip({ + startupOutput, + beforeValue, + patchedValue: patched.body.debugMode, + readBackValue: readBack.body.debugMode, + }), + // The exact value boot #2 must read back from disk to prove persistence. + expectedValue, + }; +} + function log(msg) { console.log(`[pack-boot] ${msg}`); } +/** Node sets exitCode/signalCode synchronously when the process dies — authoritative. */ +function hasExited(child) { + return child.exitCode !== null || child.signalCode !== null; +} + +/** + * SIGTERM the process GROUP and wait for its REAL exit — the graceful-shutdown handler + * (initGracefulShutdown) drains requests, checkpoints the DB via closeDbInstance(), then + * calls process.exit(0). A fixed sleep + hard kill could SIGKILL mid-flush and silently + * drop the very persistence this gate proves, so SIGKILL is a last resort after the grace + * deadline, and a CONFIRMED exit is required before returning: if even SIGKILL fails to + * reap, throw, so boot #2 cannot start against a port a zombie still holds. + * + * The child is spawned with detached:true, so it leads its own process group and + * -child.pid signals the whole tree, not just the launcher. + */ +async function stopChild(child, graceMs = 30_000) { + if (!child?.pid) return; + // Fast path: already reaped (crashed mid-smoke, or exited before this call) — nothing + // left to signal or wait for. + if (hasExited(child)) return; + + let onSettled; + const exited = new Promise((resolve) => { + onSettled = () => resolve(); + child.once("exit", onSettled); + child.once("close", onSettled); + }); + // Race the exit/close promise against a timeout; then re-read authoritative state, so a + // same-tick exit that lost the race still counts. Timer is always cleared. + const waitForExit = (ms) => { + let timer; + return Promise.race([ + exited, + new Promise((resolve) => { + timer = setTimeout(resolve, ms); + }), + ]) + .finally(() => clearTimeout(timer)) + .then(() => hasExited(child)); + }; + + try { + // Re-check AFTER attaching: if the process died in the gap between the fast path and + // listener attach, once("exit") can never fire (event already emitted), and without + // this waitForExit would burn the full grace window. + if (hasExited(child)) return; + + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + /* group already gone */ + } + if (await waitForExit(graceMs)) return; + + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + /* group already gone */ + } + if (!(await waitForExit(5_000))) { + throw new Error( + `[pack-boot] server process group ${child.pid} still alive 5s after SIGKILL — ` + + "refusing to reboot on the same port" + ); + } + } finally { + child.removeListener("exit", onSettled); + child.removeListener("close", onSettled); + } +} + +/** + * Boot the installed CLI once on an isolated DATA_DIR. The child is spawned detached:true + * so it leads its own process group — stopChild() relies on that to SIGTERM the whole tree. + * The caller owns shutdown so the graceful DB flush lands before teardown. + */ +function spawnServer(binPath, port, dataDir) { + const child = spawn(binPath, ["serve", "--port", String(port), "--log", "--no-open"], { + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + JWT_SECRET: "pack-boot-smoke-secret-with-sufficient-length-000", + API_KEY_SECRET: "pack-boot-smoke-api-key-secret-long", + DISABLE_SQLITE_AUTO_BACKUP: "true", + OMNIROUTE_SKIP_SYSTEM_TRUST: "1", + OMNIROUTE_PACK_BOOT_SMOKE: "1", + OMNIROUTE_PACK_BOOT_FORCE_SQLJS: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + const tail = []; + const keepTail = (chunk) => { + tail.push(String(chunk)); + while (tail.length > 80) tail.shift(); + }; + child.stdout.on("data", keepTail); + child.stderr.on("data", keepTail); + return { child, tail }; +} + +/** Poll /api/monitoring/health until the packed version answers or the boot deadline passes. */ +async function waitForHealthy(port, child, expectedVersion) { + // Seed from authoritative state (Node sets these synchronously at death), then attach a + // named once-listener, then re-check: a child that died before this call, or in the gap + // before the listener attached, would otherwise never fire "exit" and waste the deadline. + const exitDescriptor = (code, signal) => (signal ? `signal ${signal}` : `code ${code ?? -1}`); + let childExit = hasExited(child) ? exitDescriptor(child.exitCode, child.signalCode) : null; + const onChildExit = (code, signal) => { + childExit = exitDescriptor(code, signal); + }; + child.once("exit", onChildExit); + if (hasExited(child)) { + childExit = exitDescriptor(child.exitCode, child.signalCode); + } + + const deadline = Date.now() + BOOT_DEADLINE_MS; + let verdict = { ok: false, failures: ["never polled"] }; + try { + while (Date.now() < deadline) { + if (childExit !== null) { + return { ok: false, failures: [`process exited (${childExit}) before serving`] }; + } + try { + const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`); + const body = await res.json().catch(() => null); + verdict = evaluateBoot(res.status, body, expectedVersion); + if (verdict.ok) return verdict; + } catch { + // not listening yet — keep polling + } + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } + return verdict; + } finally { + child.removeListener("exit", onChildExit); + } +} + +/** + * Read the current debugMode setting and return the EXACT boolean. A missing or non-boolean + * field throws: coercing with `=== true` would read `false` for a malformed response and + * could falsely "pass" persistence whenever the expected value happens to be false. + */ +async function readSettingsDebugMode(baseUrl) { + const { response, body } = await readJsonResponse(`${baseUrl}/api/settings`); + if (response.status !== 200 || !body || typeof body !== "object") { + throw new Error(`settings GET HTTP ${response.status} or non-JSON body`); + } + if (typeof body.debugMode !== "boolean") { + throw new Error(`settings debugMode is ${typeof body.debugMode} (expected boolean)`); + } + return body.debugMode; +} + async function main() { const ROOT = process.cwd(); if (!fs.existsSync(path.join(ROOT, "dist", "server.js"))) { - console.error("[pack-boot] dist/server.js missing — run `npm run build:cli` first (this is a --with-build gate)"); + console.error( + "[pack-boot] dist/server.js missing — run `npm run build:cli` first (this is a --with-build gate)" + ); process.exit(2); } - const expectedVersion = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version; + const expectedVersion = JSON.parse( + fs.readFileSync(path.join(ROOT, "package.json"), "utf8") + ).version; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pack-boot-")); let child = null; + let tail = []; let exitCode = 1; + let primaryError = null; // a smoke-logic failure: boot/PATCH/GET/restart, or an in-flow stop + let cleanupError = null; // recorded ONLY in finally, ONLY for a final stopChild failure + let shutdownConfirmed = false; // process group confirmed stopped → safe to rm the workspace try { log(`packing v${expectedVersion}…`); const packOut = execFileSync("npm", ["pack", "--json", "--pack-destination", tmp], { @@ -77,87 +342,116 @@ async function main() { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, }); + const packageRoot = path.join(prefix, "lib", "node_modules", "omniroute"); + const missingSqlJsFiles = findMissingSqlJsRuntimeFiles(packageRoot); + if (missingSqlJsFiles.length > 0) { + throw new Error( + `installed package is missing the sql.js runtime contract: ${missingSqlJsFiles.join(", ")}` + ); + } + log("installed package contains the complete sql.js WASM runtime"); const port = pickPort(); const dataDir = path.join(tmp, "data"); fs.mkdirSync(dataDir, { recursive: true }); const binPath = path.join(prefix, "bin", "omniroute"); - log(`booting installed CLI on :${port} (DATA_DIR isolated)…`); - child = spawn(binPath, ["serve", "--port", String(port)], { - env: { - ...process.env, - PORT: String(port), - DATA_DIR: dataDir, - JWT_SECRET: "pack-boot-smoke-secret-with-sufficient-length-000", - API_KEY_SECRET: "pack-boot-smoke-api-key-secret-long", - DISABLE_SQLITE_AUTO_BACKUP: "true", - OMNIROUTE_SKIP_SYSTEM_TRUST: "1", - }, - stdio: ["ignore", "pipe", "pipe"], - detached: true, - }); - const tail = []; - const keepTail = (chunk) => { - tail.push(String(chunk)); - while (tail.length > 80) tail.shift(); - }; - child.stdout.on("data", keepTail); - child.stderr.on("data", keepTail); - let childExit = null; - child.on("exit", (code) => { - childExit = code ?? -1; - }); - const deadline = Date.now() + BOOT_DEADLINE_MS; - let verdict = { ok: false, failures: ["never polled"] }; - while (Date.now() < deadline) { - if (childExit !== null) { - verdict = { ok: false, failures: [`process exited with code ${childExit} before serving`] }; - break; - } - try { - const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`); - const body = await res.json().catch(() => null); - verdict = evaluateBoot(res.status, body, expectedVersion); + // BOOT #1 — boot, prove the forced sql.js tier, PATCH a setting, then shut down cleanly + // so the sql.js adapter's graceful persist actually lands on disk. The in-flow stopChild + // THROWS on failure; that lands in catch as primaryError and boot #2 never starts. + log(`boot #1: installed CLI on :${port} (DATA_DIR isolated)…`); + ({ child, tail } = spawnServer(binPath, port, dataDir)); + let verdict = await waitForHealthy(port, child, expectedVersion); + if (verdict.ok) { + log(`healthy: HTTP 200, version ${expectedVersion}`); + const roundTrip = await verifySettingsRoundTrip(`http://127.0.0.1:${port}`, tail.join("")); + if (roundTrip.ok) { + log("settings write/read succeeded through the forced sql.js driver"); + await stopChild(child); // throws here → primaryError; boot #2 is skipped + child = null; + + // BOOT #2 — same DATA_DIR, fresh process: the value must be read back FROM DISK. + log("boot #2: rebooting on the same DATA_DIR to prove disk persistence…"); + ({ child, tail } = spawnServer(binPath, port, dataDir)); + verdict = await waitForHealthy(port, child, expectedVersion); if (verdict.ok) { - log(`healthy: HTTP 200, version ${body.version}, status "${body.status}"`); - break; + log(`healthy: HTTP 200, version ${expectedVersion}`); + const restartValue = await readSettingsDebugMode(`http://127.0.0.1:${port}`); + const persistence = evaluateRestartPersistence({ + expectedValue: roundTrip.expectedValue, + restartValue, + }); + if (persistence.ok) { + log("value survived a clean shutdown + restart — disk persistence proven"); + await stopChild(child); // throws here → primaryError + child = null; + exitCode = 0; + } else { + verdict = persistence; + } } - } catch { - // not listening yet — keep polling + } else { + verdict = roundTrip; } - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } - - if (verdict.ok) { - log("✅ the packed tarball boots — #7065 class gate green"); - exitCode = 0; - } else { - console.error(`[pack-boot] ❌ boot FAILED: ${verdict.failures.join("; ")}`); - console.error("[pack-boot] last server output:\n" + tail.join("").split("\n").slice(-40).join("\n")); + if (!verdict.ok) { + primaryError = new Error(verdict.failures.join("; ")); exitCode = 1; } + } catch (e) { + // Every smoke-logic failure — boot/PATCH/GET/restart AND in-flow stopChild throws. + primaryError = e; + exitCode = 1; } finally { - if (child?.pid) { + // Tear down whatever is still running. This block records ONLY a stopChild failure, + // and never overwrites primaryError. + if (child) { try { - process.kill(-child.pid, "SIGTERM"); - } catch { - /* already gone */ - } - await new Promise((r) => setTimeout(r, 2_000)); - try { - process.kill(-child.pid, "SIGKILL"); - } catch { - /* already gone */ + await stopChild(child); + shutdownConfirmed = true; + } catch (e) { + cleanupError = e; // still !shutdownConfirmed → workspace preserved below } + child = null; + } else { + // Stopped in-flow (already confirmed) or never spawned — nothing left to confirm. + shutdownConfirmed = true; + } + // Remove the workspace ONLY after confirmed shutdown; a process group that refused to + // die keeps its DATA_DIR for diagnosis. + if (shutdownConfirmed) { + fs.rmSync(tmp, { recursive: true, force: true }); } - fs.rmSync(tmp, { recursive: true, force: true }); + } + + // Report primaryError as the smoke failure; report cleanupError separately. Either one + // fails the gate. + if (primaryError) { + console.error(`[pack-boot] ❌ smoke FAILED: ${primaryError.message}`); + if (tail.length) { + console.error( + "[pack-boot] last server output:\n" + tail.join("").split("\n").slice(-40).join("\n") + ); + } + } + if (cleanupError) { + console.error(`[pack-boot] ❌ final shutdown FAILED: ${cleanupError.message}`); + exitCode = 1; + } + if (exitCode === 0) { + log("✅ the packed tarball boots AND persists — #7065 class gate green"); + } + if (!shutdownConfirmed) { + console.error( + `[pack-boot] ⚠ process group not confirmed stopped — workspace preserved for diagnosis: ${tmp}` + ); } process.exit(exitCode); } const isDirectRun = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); if (isDirectRun) { main().catch((e) => { console.error("[pack-boot] fatal:", e.message); diff --git a/src/lib/db/adapters/driverFactory.ts b/src/lib/db/adapters/driverFactory.ts index 008fd8225b2..24a6fb08fb9 100644 --- a/src/lib/db/adapters/driverFactory.ts +++ b/src/lib/db/adapters/driverFactory.ts @@ -12,6 +12,48 @@ const _require = createRequire(import.meta.url); type DriverLoader = (moduleName: string) => unknown; +/** + * The production loader for the sync driver cascade. + * + * WHY A SWITCH INSTEAD OF PASSING `_require` DIRECTLY + * --------------------------------------------------- + * `createSyncDriverFactory(load)` takes the loader as a parameter so the driver + * branches stay testable. But webpack (the Next.js server build) only recognizes a + * require when it can read the module id as a literal at the call site: + * + * _require("better-sqlite3") → a real external: `module.exports = require("better-sqlite3")` + * load("better-sqlite3") → unanalyzable, so the loader ITSELF is replaced + * + * In the second case webpack cannot see what `load` is, so the value passed in is + * replaced by its "missing module" stub — a function whose only behavior is + * `throw Error("Cannot find module '" + id + "'")` with `code = "MODULE_NOT_FOUND"`. + * Every driver in the cascade then reports itself as not installed even though the + * addon is present on disk, the whole cascade falls through to the sql.js WASM last + * resort, and startup dies there instead — pointing the blame at sql.js rather than at + * the bundling. Observed in the packaged v3.8.49 server build, where the driver chunk + * contains that stub and NO `require("better-sqlite3")` external, while the previous + * release's chunk (before the loader became injectable) contains the external and no + * stub. Not reproducible from source: `tsx`/`node --test` resolve the injected + * `_require` normally, so the existing unit tests pass either way. + * + * Naming each module in a direct `_require("")` call restores the externals + * webpack emitted before the loader became injectable, while keeping the seam intact. + * Keep the literals literal: hoisting them into a constant or a map keyed by variable + * re-breaks the analysis. + */ +function requireSqliteDriver(moduleName: string): unknown { + switch (moduleName) { + case "bun:sqlite": + return _require("bun:sqlite"); + case "better-sqlite3": + return _require("better-sqlite3"); + case "node:sqlite": + return _require("node:sqlite"); + default: + throw new Error(`Unsupported SQLite driver module: ${moduleName}`); + } +} + type NodeSqliteOptions = { readOnly?: boolean; }; @@ -151,8 +193,25 @@ export function createSyncDriverFactory(load: DriverLoader) { }; } +const openSyncDriver = createSyncDriverFactory(requireSqliteDriver); + +/** + * The installed-tarball smoke uses this paired marker to exercise the sql.js tier + * even on runners where better-sqlite3 or node:sqlite is available. Requiring both + * pack-boot-specific flags keeps this from becoming a general operator override. + */ +export function isPackBootForcedSqlJsSmoke(env: NodeJS.ProcessEnv): boolean { + return env.OMNIROUTE_PACK_BOOT_SMOKE === "1" && env.OMNIROUTE_PACK_BOOT_FORCE_SQLJS === "1"; +} + /** Tenta abrir com better-sqlite3 e node:sqlite sincronamente. Retorna null se ambos falharem. */ -export const tryOpenSync = createSyncDriverFactory(_require); +export function tryOpenSync( + filePath: string, + options?: Record +): SqliteAdapter | null { + if (isPackBootForcedSqlJsSmoke(process.env)) return null; + return openSyncDriver(filePath, options); +} /** * Pré-inicializa sql.js para um filePath. diff --git a/src/lib/db/adapters/sqljsAdapter.ts b/src/lib/db/adapters/sqljsAdapter.ts index 16ce501fb51..ba738256752 100644 --- a/src/lib/db/adapters/sqljsAdapter.ts +++ b/src/lib/db/adapters/sqljsAdapter.ts @@ -1,16 +1,19 @@ // src/lib/db/adapters/sqljsAdapter.ts import fs from "node:fs"; -import { createRequire } from "node:module"; import path from "node:path"; import type { SqliteAdapter, PreparedStatement, RunResult } from "./types"; const SAVE_DEBOUNCE_MS = 100; const CHECKPOINT_INTERVAL_MS = 60_000; -const _require = createRequire(import.meta.url); let _sqlJsLib: Awaited> | null = null; function resolveSqlJsWasmPath(): string { + // The standalone assembler copies the complete sql.js package into + // /node_modules/sql.js. Every packaged server launcher sets cwd to that + // bundle directory, so the JavaScript entrypoint and its sibling WASM share one + // explicit runtime contract instead of relying on a require.resolve call that + // webpack can rewrite. The second path retains direct-source compatibility. const candidatePaths = [ path.join(process.cwd(), "node_modules", "sql.js", "dist", "sql-wasm.wasm"), path.join( @@ -24,14 +27,6 @@ function resolveSqlJsWasmPath(): string { ), ]; - // Global Bun installs do not use the application's cwd as the package root. - // Resolve the actual JavaScript entrypoint so sql.js can find its sibling WASM - // asset when OmniRoute is launched from ~/.bun/install/global. - try { - const sqlJsEntry = _require.resolve("sql.js"); - candidatePaths.push(path.join(path.dirname(sqlJsEntry), "sql-wasm.wasm")); - } catch {} - for (const candidatePath of candidatePaths) { if (fs.existsSync(candidatePath)) { return candidatePath; @@ -39,7 +34,9 @@ function resolveSqlJsWasmPath(): string { } throw new Error( - `[sqljsAdapter] Could not locate sql-wasm.wasm. Checked:\n${candidatePaths.join("\n")}` + `[sqljsAdapter] Packaged sql.js runtime is incomplete: sql-wasm.wasm was not found. Checked:\n${candidatePaths.join( + "\n" + )}` ); } diff --git a/tests/unit/build/assemble-standalone.test.ts b/tests/unit/build/assemble-standalone.test.ts index c87a1419d1d..323f995b064 100644 --- a/tests/unit/build/assemble-standalone.test.ts +++ b/tests/unit/build/assemble-standalone.test.ts @@ -40,6 +40,9 @@ function seedSidecarSources(root: string) { "node_modules/pino-pretty/index.js", "node_modules/split2/index.js", "node_modules/playwright-core/index.js", + "node_modules/sql.js/package.json", + "node_modules/sql.js/dist/sql-wasm.js", + "node_modules/sql.js/dist/sql-wasm.wasm", "node_modules/sqlite-vec/index.js", "node_modules/sqlite-vec-linux-x64/vec0.so", "src/lib/db/migrations/001_init.sql", @@ -162,6 +165,13 @@ test("async and sync sidecar copy paths produce identical bundle trees", async ( asyncTree.includes("src/mitm/tproxy/native/build/Release/transparent.node"), "TPROXY transparent.node copied into the standalone bundle" ); + for (const sqlJsFile of [ + "node_modules/sql.js/package.json", + "node_modules/sql.js/dist/sql-wasm.js", + "node_modules/sql.js/dist/sql-wasm.wasm", + ]) { + assert.ok(asyncTree.includes(sqlJsFile), `sql.js runtime file copied: ${sqlJsFile}`); + } fs.rmSync(tmp, { recursive: true, force: true }); }); diff --git a/tests/unit/check-pack-boot.test.ts b/tests/unit/check-pack-boot.test.ts index b22ca557b5f..ea592db217a 100644 --- a/tests/unit/check-pack-boot.test.ts +++ b/tests/unit/check-pack-boot.test.ts @@ -3,7 +3,15 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { pickTarball, evaluateBoot, pickPort } from "../../scripts/check/check-pack-boot.mjs"; +import { + REQUIRED_SQLJS_RUNTIME_FILES, + pickTarball, + evaluateBoot, + pickPort, + findMissingSqlJsRuntimeFiles, + evaluateSqlJsRoundTrip, + evaluateRestartPersistence, +} from "../../scripts/check/check-pack-boot.mjs"; // WS1.2 (T1, v3.8.49 quality plan) — pure-function guards for the tarball boot-smoke // gate that kills the #7065 class (published artifact crashes on every boot because a @@ -16,7 +24,10 @@ const SCRIPT_PATH = path.join( ); test("pickTarball extracts the filename from npm pack --json output", () => { - assert.equal(pickTarball('[{"filename":"omniroute-3.8.49.tgz","size":1}]'), "omniroute-3.8.49.tgz"); + assert.equal( + pickTarball('[{"filename":"omniroute-3.8.49.tgz","size":1}]'), + "omniroute-3.8.49.tgz" + ); }); test("pickTarball normalizes scoped slashes to the on-disk dash form", () => { @@ -49,9 +60,112 @@ test("pickPort stays inside the reserved smoke range for any pid", () => { } }); +test("installed package contract requires sql.js metadata, entrypoint, and WASM", () => { + const present = new Set(REQUIRED_SQLJS_RUNTIME_FILES.map((file) => path.join("/pkg", file))); + assert.deepEqual( + findMissingSqlJsRuntimeFiles("/pkg", (file) => present.has(file)), + [] + ); + + present.delete(path.join("/pkg", "dist/node_modules/sql.js/dist/sql-wasm.wasm")); + assert.deepEqual( + findMissingSqlJsRuntimeFiles("/pkg", (file) => present.has(file)), + ["dist/node_modules/sql.js/dist/sql-wasm.wasm"] + ); +}); + +test("sql.js round trip requires the forced-driver marker plus PATCH and GET persistence", () => { + const passing = evaluateSqlJsRoundTrip({ + startupOutput: "[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)...", + beforeValue: true, + patchedValue: false, + readBackValue: false, + }); + assert.deepEqual(passing, { ok: true, failures: [] }); + + const failing = evaluateSqlJsRoundTrip({ + startupOutput: "[DB] SQLite database ready", + beforeValue: false, + patchedValue: true, + readBackValue: false, + }); + assert.equal(failing.ok, false); + assert.equal(failing.failures.length, 2); + assert.match(failing.failures[0], /forced sql\.js startup path/); + assert.match(failing.failures[1], /GET debugMode/); +}); + test("source guard: the gate polls the real health endpoint of the INSTALLED binary", () => { const src = readFileSync(SCRIPT_PATH, "utf8"); - assert.ok(src.includes('"install", "-g", "--prefix"'), "must install the packed tarball into a clean prefix"); + assert.ok( + src.includes('"install", "-g", "--prefix"'), + "must install the packed tarball into a clean prefix" + ); assert.ok(src.includes("/api/monitoring/health"), "must poll the health endpoint"); + assert.ok(src.includes("/api/settings"), "must verify a real application write and read"); + assert.ok( + src.includes('OMNIROUTE_PACK_BOOT_FORCE_SQLJS: "1"'), + "must force the packaged sql.js tier during this smoke" + ); assert.ok(src.indexOf("npm") < src.indexOf("spawn"), "pack+install must precede the boot spawn"); }); + +test("restart persistence requires the reboot value to match the boot #1 written value", () => { + assert.deepEqual(evaluateRestartPersistence({ expectedValue: true, restartValue: true }), { + ok: true, + failures: [], + }); + assert.deepEqual(evaluateRestartPersistence({ expectedValue: false, restartValue: false }), { + ok: true, + failures: [], + }); + + const mismatch = evaluateRestartPersistence({ expectedValue: true, restartValue: false }); + assert.equal(mismatch.ok, false); + assert.equal(mismatch.failures.length, 1); + assert.match(mismatch.failures[0], /after restart/); +}); + +test("source guard: the gate reboots on the SAME DATA_DIR and reads debugMode as a strict boolean", () => { + const src = readFileSync(SCRIPT_PATH, "utf8"); + assert.ok(src.includes("boot #2"), "must run a second boot to prove disk persistence"); + + // Count only the CALLS, not the `function spawnServer(` declaration: the calls are the + // destructuring-assignment form `= spawnServer(...)`. Capture each call's arg list and + // assert both pass the SAME shared dataDir variable — that is what makes boot #2 read + // boot #1's disk state. + const calls = [...src.matchAll(/= spawnServer\(([^)]*)\)/g)]; + assert.equal(calls.length, 2, "must spawn exactly two boots (write, then reboot to verify)"); + for (const call of calls) { + assert.equal( + call[1], + "binPath, port, dataDir", + "both boots must pass the same shared dataDir variable" + ); + } + + assert.ok( + src.includes("evaluateRestartPersistence"), + "must evaluate the value read back after the reboot" + ); + // readSettingsDebugMode must reject a missing/malformed field instead of coercing it, or + // a false expectedValue could pass on an empty response. + assert.ok( + src.includes('typeof body.debugMode !== "boolean"'), + "must require debugMode to be a real boolean, not coerce it" + ); +}); + +test("source guard: final shutdown only deletes the workspace after a CONFIRMED stop", () => { + const src = readFileSync(SCRIPT_PATH, "utf8"); + assert.ok( + src.includes("shutdownConfirmed"), + "must gate temp-dir deletion on a confirmed process-group stop" + ); + assert.ok(src.includes("primaryError"), "must report the smoke failure distinctly"); + assert.ok(src.includes("cleanupError"), "must report a final-shutdown failure distinctly"); + assert.ok( + src.includes("hasExited(child)"), + "stopChild/waitForHealthy must read authoritative exit state, not a stale boolean" + ); +}); diff --git a/tests/unit/db-adapters/driverFactory.test.ts b/tests/unit/db-adapters/driverFactory.test.ts index 8870ab6e331..1750ba52ff5 100644 --- a/tests/unit/db-adapters/driverFactory.test.ts +++ b/tests/unit/db-adapters/driverFactory.test.ts @@ -5,8 +5,14 @@ import os from "node:os"; import path from "node:path"; import { createRequire } from "node:module"; -const { createSyncDriverFactory, tryOpenSync, openDatabaseAsync, preInitSqlJs, getSqlJsAdapter } = - await import("../../../src/lib/db/adapters/driverFactory.ts"); +const { + createSyncDriverFactory, + isPackBootForcedSqlJsSmoke, + tryOpenSync, + openDatabaseAsync, + preInitSqlJs, + getSqlJsAdapter, +} = await import("../../../src/lib/db/adapters/driverFactory.ts"); const require = createRequire(import.meta.url); const isBun = Boolean(process.versions.bun); @@ -171,6 +177,19 @@ describe("driverFactory", () => { assert.equal(openWithoutNativeDrivers(":memory:"), null); }); + test("pack-boot sql.js forcing requires both smoke-only markers", () => { + assert.equal(isPackBootForcedSqlJsSmoke({}), false); + assert.equal(isPackBootForcedSqlJsSmoke({ OMNIROUTE_PACK_BOOT_SMOKE: "1" }), false); + assert.equal(isPackBootForcedSqlJsSmoke({ OMNIROUTE_PACK_BOOT_FORCE_SQLJS: "1" }), false); + assert.equal( + isPackBootForcedSqlJsSmoke({ + OMNIROUTE_PACK_BOOT_SMOKE: "1", + OMNIROUTE_PACK_BOOT_FORCE_SQLJS: "1", + }), + true + ); + }); + test("openDatabaseAsync sempre retorna um adapter válido", async () => { const adapter = await openDatabaseAsync(":memory:"); assert.ok(["better-sqlite3", "node:sqlite", "bun:sqlite", "sql.js"].includes(adapter.driver)); diff --git a/tests/unit/db-driver-bundling-externals.test.ts b/tests/unit/db-driver-bundling-externals.test.ts new file mode 100644 index 00000000000..b1d920dd0e3 --- /dev/null +++ b/tests/unit/db-driver-bundling-externals.test.ts @@ -0,0 +1,51 @@ +// Guards the native `require` shape that webpack silently rewrites when the +// module specifier (or the require itself) is not statically analyzable. +// +// This failure cannot be caught by running the code: under `tsx`/`node --test` the +// injected loader behaves normally, so the existing driverFactory tests pass in BOTH +// the broken and fixed shapes. The damage only appears in a packaged Next server build. +// The sql.js fallback is covered separately through package assembly and installed- +// artifact boot/write/read outcomes; do not pin another resolver implementation here. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +function readSource(relativePath: string): string { + return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); +} + +/** + * Strips comments before shape-matching. Both files document the rewritten forms they + * must avoid, so a scan of the raw text matches its own warning and fails on the FIXED + * source — a guard that can only ever be satisfied by deleting the explanation. + */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, ""); +} + +test("sync driver cascade requires each SQLite module by literal specifier", () => { + const driverFactory = stripComments(readSource("src/lib/db/adapters/driverFactory.ts")); + + // Positive anchor: proves the read hit the real, non-empty module (#8619). + assert.match(driverFactory, /^export function createSyncDriverFactory\(/m); + + // The production loader must be the literal-specifier wrapper, never `_require` + // itself — passing `_require` through the `load` parameter is exactly what makes + // webpack substitute its missing-module stub. + assert.match(driverFactory, /^const openSyncDriver = createSyncDriverFactory\(\w+\);$/m); + assert.match(driverFactory, /^export function tryOpenSync\($/m); + assert.doesNotMatch(driverFactory, /createSyncDriverFactory\(\s*_require\s*\)/); + + // Every driver the cascade can ask for needs a direct `_require("")` so + // webpack emits a real external for it. + for (const moduleName of ["bun:sqlite", "better-sqlite3", "node:sqlite"]) { + assert.ok( + driverFactory.includes(`_require("${moduleName}")`), + `driverFactory must call _require("${moduleName}") with a literal specifier so webpack emits an external for it` + ); + } +}); diff --git a/tests/unit/sqljs-build-warning-8135.test.ts b/tests/unit/sqljs-build-warning-8135.test.ts index 5e240076023..95c8752403f 100644 --- a/tests/unit/sqljs-build-warning-8135.test.ts +++ b/tests/unit/sqljs-build-warning-8135.test.ts @@ -36,9 +36,9 @@ test("#8135: sqljsAdapter must not statically resolve sql.js at build time", () "sqljsAdapter dynamic import should include /* webpackIgnore: true */ magic comment" ); - // sql.js does not export ./package.json. Resolving its public entrypoint is - // sufficient to locate the adjacent WASM asset and avoids repeated bundler - // diagnostics for the private package metadata subpath. - assert.match(source, /_require\.resolve\(["']sql\.js["']\)/); - assert.doesNotMatch(source, /sql\.js\/package\.json/); + // The standalone assembler ships sql.js as a real runtime package, so the + // adapter must not depend on a build-time createRequire/require.resolve lookup. + assert.doesNotMatch(source, /createRequire/); + assert.doesNotMatch(source, /\.resolve\(["']sql\.js["']\)/); + assert.match(source, /process\.cwd\(\)[\s\S]*"node_modules"[\s\S]*"sql\.js"/); });