From 5a241ffca996004a4e781300d0db211c4356d310 Mon Sep 17 00:00:00 2001 From: Felipe Almeman <4226997+zhiru@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:17:39 -0300 Subject: [PATCH] fix(dev): auto-rebuild better-sqlite3 on Node ABI mismatch at dev startup (#3301) Integrated into release/v3.8.13 --- scripts/dev/ensure-native-sqlite.mjs | 114 ++++++++++++++++++++ scripts/dev/run-next.mjs | 7 ++ tests/unit/dev-ensure-native-sqlite.test.ts | 112 +++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 scripts/dev/ensure-native-sqlite.mjs create mode 100644 tests/unit/dev-ensure-native-sqlite.test.ts diff --git a/scripts/dev/ensure-native-sqlite.mjs b/scripts/dev/ensure-native-sqlite.mjs new file mode 100644 index 0000000000..1edbeb8268 --- /dev/null +++ b/scripts/dev/ensure-native-sqlite.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +/** + * OmniRoute — Dev-startup native SQLite ABI guard. + * + * `better-sqlite3` is a native addon compiled for a specific Node.js ABI + * (NODE_MODULE_VERSION). This project supports both Node 22 (ABI 127) and + * Node 24 (ABI 137); switching between them via nvm leaves the previously + * built `better_sqlite3.node` incompatible, so `npm run dev` crashes during + * bootstrap with: + * + * "The module '…/better_sqlite3.node' was compiled against a different + * Node.js version using NODE_MODULE_VERSION 127. This version of Node.js + * requires NODE_MODULE_VERSION 137." + * + * `postinstall.mjs` only fixes the published standalone bundle and only runs + * on `npm install` — it does NOT cover "cloned repo, switched Node, ran dev". + * + * This guard probes the root binary against the *current* Node ABI and, ONLY + * when it detects a genuine ABI mismatch, runs `npm rebuild better-sqlite3` + * once. The healthy path (matching ABI) does no work, so dev startup stays + * fast. Unrelated errors are NOT swallowed — they fall through so the normal + * bootstrap surfaces them. + */ + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", ".."); + +export const SQLITE_BINARY = join( + ROOT, + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" +); + +/** + * Whether an error message indicates a native-addon ABI / load mismatch + * (as opposed to an unrelated runtime error such as a missing table). + * Mirrors the detection in src/lib/db/core.ts::isNativeSqliteLoadError. + * @param {unknown} message + * @returns {boolean} + */ +export function isNativeAbiMismatch(message) { + const m = String(message ?? ""); + return ( + m.includes("NODE_MODULE_VERSION") || + m.includes("was compiled against a different Node.js version") || + m.includes("Module did not self-register") || + m.includes("ERR_DLOPEN_FAILED") || + m.includes("Could not locate the bindings file") + ); +} + +/** Probe a native binary against the current Node ABI without polluting the require cache. */ +function probeLoad(binaryPath) { + process.dlopen({ exports: {} }, binaryPath); +} + +/** Default rebuild: `npm rebuild better-sqlite3` at the repo root (no shell interpolation). */ +function defaultRebuild() { + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const result = spawnSync(npm, ["rebuild", "better-sqlite3"], { cwd: ROOT, stdio: "inherit" }); + return result.status === 0; +} + +/** + * Ensure better-sqlite3 loads under the current Node. Rebuilds once on ABI + * mismatch. Returns a result object; never throws for the mismatch path. + * + * @param {{ logger?: Pick, rebuild?: () => boolean, probe?: (p: string) => void, binaryPath?: string }} [opts] + * @returns {{ ok: boolean, rebuilt: boolean, error?: unknown }} + */ +export function ensureNativeSqlite(opts = {}) { + const { + logger = console, + rebuild = defaultRebuild, + probe = probeLoad, + binaryPath = SQLITE_BINARY, + } = opts; + + // Nothing built yet (fresh clone before install) — let install/bootstrap handle it. + if (!existsSync(binaryPath)) return { ok: true, rebuilt: false }; + + try { + probe(binaryPath); + return { ok: true, rebuilt: false }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!isNativeAbiMismatch(message)) { + // Not an ABI problem — do not mask it; bootstrap will surface the real error. + return { ok: false, rebuilt: false, error }; + } + logger.warn( + `[dev] better-sqlite3 was built for a different Node ABI than ${process.version} — ` + + "rebuilding (one-time)…" + ); + if (!rebuild()) { + logger.error( + "[dev] Automatic 'npm rebuild better-sqlite3' failed. Run it manually:\n" + + " npm rebuild better-sqlite3" + ); + return { ok: false, rebuilt: false }; + } + logger.log("[dev] better-sqlite3 rebuilt for the current Node. Continuing startup."); + return { ok: true, rebuilt: true }; + } +} diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index 7a956d93a9..23a6d326ed 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -9,6 +9,7 @@ import { resolveRuntimePorts, withRuntimePortEnv } from "../build/runtime-env.mj import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs"; +import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs"; import { randomUUID } from "node:crypto"; // Pre-read DATA_DIR from local .env before bootstrap resolves paths @@ -36,6 +37,12 @@ if (fs.existsSync(rootAppDir) && fs.statSync(rootAppDir).isDirectory()) { const mode = process.argv[2] === "start" ? "start" : "dev"; const dev = mode === "dev"; +// Self-heal a stale better-sqlite3 native binary after a Node version switch +// (nvm 22 <-> 24) before bootstrap touches the DB. No-op when the ABI matches. +if (dev) { + ensureNativeSqlite(); +} + const bootstrappedEnv = bootstrapEnv(); const runtimePorts = resolveRuntimePorts(bootstrappedEnv); const mergedEnv = withRuntimePortEnv(bootstrappedEnv, runtimePorts); diff --git a/tests/unit/dev-ensure-native-sqlite.test.ts b/tests/unit/dev-ensure-native-sqlite.test.ts new file mode 100644 index 0000000000..31fd39c886 --- /dev/null +++ b/tests/unit/dev-ensure-native-sqlite.test.ts @@ -0,0 +1,112 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + ensureNativeSqlite, + isNativeAbiMismatch, +} from "../../scripts/dev/ensure-native-sqlite.mjs"; + +// A binary path that is guaranteed to exist so the existsSync() guard passes; +// the injected probe controls the actual outcome. +const EXISTING_PATH = process.execPath; +const silentLogger = { warn() {}, error() {}, log() {} }; + +// The exact message a Node 24 process produces against a Node 22 (ABI 127) binary. +const ABI_ERROR = + "The module '/x/node_modules/better-sqlite3/build/Release/better_sqlite3.node' " + + "was compiled against a different Node.js version using NODE_MODULE_VERSION 127. " + + "This version of Node.js requires NODE_MODULE_VERSION 137."; + +test("isNativeAbiMismatch detects ABI / native-load errors", () => { + assert.equal(isNativeAbiMismatch(ABI_ERROR), true); + assert.equal(isNativeAbiMismatch("Module did not self-register"), true); + assert.equal(isNativeAbiMismatch("ERR_DLOPEN_FAILED: bad bits"), true); + assert.equal(isNativeAbiMismatch("Could not locate the bindings file"), true); +}); + +test("isNativeAbiMismatch ignores unrelated errors", () => { + assert.equal(isNativeAbiMismatch("SQLITE_ERROR: no such table: foo"), false); + assert.equal(isNativeAbiMismatch("ENOENT: no such file"), false); + assert.equal(isNativeAbiMismatch(""), false); + assert.equal(isNativeAbiMismatch(null), false); + assert.equal(isNativeAbiMismatch(undefined), false); +}); + +test("ensureNativeSqlite: healthy binary does nothing (fast path)", () => { + let rebuilt = 0; + const res = ensureNativeSqlite({ + logger: silentLogger, + binaryPath: EXISTING_PATH, + probe: () => { + /* loads fine */ + }, + rebuild: () => { + rebuilt++; + return true; + }, + }); + assert.deepEqual(res, { ok: true, rebuilt: false }); + assert.equal(rebuilt, 0, "must not rebuild when the ABI already matches"); +}); + +test("ensureNativeSqlite: ABI mismatch triggers exactly one rebuild", () => { + let rebuilt = 0; + const res = ensureNativeSqlite({ + logger: silentLogger, + binaryPath: EXISTING_PATH, + probe: () => { + throw new Error(ABI_ERROR); + }, + rebuild: () => { + rebuilt++; + return true; + }, + }); + assert.equal(res.ok, true); + assert.equal(res.rebuilt, true); + assert.equal(rebuilt, 1, "rebuild must run once on ABI mismatch"); +}); + +test("ensureNativeSqlite: failed rebuild reports ok=false", () => { + const res = ensureNativeSqlite({ + logger: silentLogger, + binaryPath: EXISTING_PATH, + probe: () => { + throw new Error(ABI_ERROR); + }, + rebuild: () => false, + }); + assert.equal(res.ok, false); + assert.equal(res.rebuilt, false); +}); + +test("ensureNativeSqlite: unrelated load error is NOT swallowed and does not rebuild", () => { + let rebuilt = 0; + const res = ensureNativeSqlite({ + logger: silentLogger, + binaryPath: EXISTING_PATH, + probe: () => { + throw new Error("SQLITE_CANTOPEN: unable to open database file"); + }, + rebuild: () => { + rebuilt++; + return true; + }, + }); + assert.equal(res.ok, false); + assert.equal(res.rebuilt, false); + assert.ok(res.error instanceof Error); + assert.equal(rebuilt, 0, "must not rebuild for unrelated errors"); +}); + +test("ensureNativeSqlite: missing binary is a no-op (pre-install)", () => { + const res = ensureNativeSqlite({ + logger: silentLogger, + binaryPath: "/path/that/does/not/exist/better_sqlite3.node", + probe: () => { + throw new Error("should not be called"); + }, + rebuild: () => true, + }); + assert.deepEqual(res, { ok: true, rebuilt: false }); +});