Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions scripts/dev/ensure-native-sqlite.mjs
Original file line number Diff line number Diff line change
@@ -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")
);
Comment on lines +52 to +58
}

/** Probe a native binary against the current Node ABI without polluting the require cache. */
function probeLoad(binaryPath) {
process.dlopen({ exports: {} }, binaryPath);
}
Comment on lines +61 to +64
Comment on lines +61 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Calling process.dlopen directly on a native addon in the main process will cause Node.js to throw Error: Module did not self-register when the same native addon is loaded later via require or import (e.g., when better-sqlite3 is initialized during bootstrap).

This happens because Node's native module loader does not allow a non-context-aware native addon to be registered multiple times with different module/exports objects in the same process. On the second load, the OS dynamic linker returns the already-loaded library handle without re-running the static initializers, causing Node to fail registration.

To prevent this, run the probe in an isolated child process using spawnSync.

Suggested change
/** Probe a native binary against the current Node ABI without polluting the require cache. */
function probeLoad(binaryPath) {
process.dlopen({ exports: {} }, binaryPath);
}
/** Probe a native binary against the current Node ABI in an isolated child process to avoid registration pollution. */
function probeLoad(binaryPath) {
const result = spawnSync(
process.execPath,
[
"--eval",
"try { process.dlopen({ exports: {} }, process.argv[1]); } catch (err) { console.error(err.message); process.exit(1); }",
binaryPath,
],
{ stdio: ["ignore", "pipe", "pipe"] }
);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const msg = result.stderr?.toString().trim() || result.stdout?.toString().trim() || "DLOPEN_FAILED";
throw new Error(msg);
}
}


/** 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<Console,"warn"|"error"|"log">, 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 };
}
Comment on lines +94 to +99
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 };
Comment on lines +104 to +112
}
}
7 changes: 7 additions & 0 deletions scripts/dev/run-next.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
Comment on lines +40 to +44
Comment on lines +42 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The return value of ensureNativeSqlite() is currently ignored. If the automatic rebuild fails or an unrelated error occurs, the startup process will continue and crash later with a less clear error.

We should fail fast by checking the result. If there is an unrelated error, we should throw it immediately to preserve the stack trace. If the rebuild failed, we should exit the process with a non-zero status code.

if (dev) {
  const res = ensureNativeSqlite();
  if (!res.ok) {
    if (res.error) {
      throw res.error;
    }
    process.exit(1);
  }
}


const bootstrappedEnv = bootstrapEnv();
const runtimePorts = resolveRuntimePorts(bootstrappedEnv);
const mergedEnv = withRuntimePortEnv(bootstrappedEnv, runtimePorts);
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/dev-ensure-native-sqlite.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading