diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd4a94..c1ee99b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,18 @@ attribution, not the operator's own name, so on an instance branded "Acme" it of branding. It now reads "Hide platform branding", and the equivalent in the six other translated languages. -Nothing else changed. + +### Fixed — a missing database driver is repaired again on Node 20 and 22 + +1.9.38 made `better-sqlite3` an optional dependency, so that a host without a compiler installs +cleanly and falls back to Node's built-in driver. That fallback needs Node 24: on the 20.x and 22.x +lines the built-in is absent or behind a flag, and there a missing `better-sqlite3` means the server +cannot start at all. + +npm will quietly skip an optional dependency whose install script it declines to run — leaving an +install that reports success and a server that will not boot. The startup check now treats optional +dependencies as required on any host that has no built-in driver, and repairs them like any other +missing package. ### Upgrading diff --git a/server/lib/preflight-deps.js b/server/lib/preflight-deps.js index d8cb64b..3854ad6 100644 --- a/server/lib/preflight-deps.js +++ b/server/lib/preflight-deps.js @@ -46,6 +46,32 @@ const SERVER_DIR = path.join(__dirname, '..'); const NODE_MODULES = path.join(SERVER_DIR, 'node_modules'); const INSTALL_TIMEOUT_MS = 10 * 60 * 1000; // a cold install on a Pi is genuinely slow +/** + * Which dependency names this host actually needs on disk. + * + * ⚠️ OPTIONAL IS NOT OPTIONAL WHERE THERE IS NOTHING TO FALL BACK TO. + * + * better-sqlite3 is an optionalDependency so a host with no compiler installs cleanly and lets + * db/sqlite-driver.js drop to the built-in node:sqlite. That only works on Node 24 (23.4+); on the + * 20.x and 22.x lines the built-in is absent or flagged off, and there a missing better-sqlite3 is + * a server that cannot start at all. + * + * npm will quietly skip an optional dependency whose install script it declines to run. Observed + * exactly that: `npm ci` reported success, left no better-sqlite3 on disk, and every test that + * spawns the server failed with "server did not boot" — with nothing anywhere saying why. + * + * Split out from missingDeps so the decision can be tested on either kind of host, rather than only + * on whichever Node happens to be running the suite. + * + * @param {{dependencies?: object, optionalDependencies?: object}} pkg + * @param {boolean} builtinDriver whether this runtime has node:sqlite + */ +function requiredDeps(pkg, builtinDriver) { + const names = Object.keys((pkg && pkg.dependencies) || {}); + if (!builtinDriver) names.push(...Object.keys((pkg && pkg.optionalDependencies) || {})); + return names; +} + /** Which declared dependencies are not on disk. */ function missingDeps() { let pkg; @@ -54,7 +80,9 @@ function missingDeps() { } catch { return []; // no package.json is not our problem to diagnose } - const declared = Object.keys(pkg.dependencies || {}); + let builtinDriver = false; + try { require('node:sqlite'); builtinDriver = true; } catch { /* absent on this runtime */ } + const declared = requiredDeps(pkg, builtinDriver); return declared.filter((name) => { // A scoped or nested name is still one directory below node_modules. try { return !fs.existsSync(path.join(NODE_MODULES, name, 'package.json')); } catch { return true; } @@ -237,4 +265,4 @@ function preflight() { } } -module.exports = { preflight, missingDeps, nativeModuleBroken }; +module.exports = { preflight, missingDeps, nativeModuleBroken, requiredDeps }; diff --git a/server/test/preflight-deps.test.js b/server/test/preflight-deps.test.js index 1d68c92..21fb951 100644 --- a/server/test/preflight-deps.test.js +++ b/server/test/preflight-deps.test.js @@ -90,3 +90,33 @@ test('server.js runs the preflight BEFORE requiring anything', () => { assert.ok(preflightAt > -1, 'server.js must run the preflight'); assert.ok(preflightAt < firstDep, 'it must come before the first dependency, or it cannot help'); }); + +/* + * ⚠️ AN OPTIONAL DEPENDENCY IS NOT OPTIONAL ON A HOST WITH NO FALLBACK. + * + * better-sqlite3 became optional so a compiler-less host installs cleanly and drops to the built-in + * node:sqlite. But the built-in is Node 24 in practice — absent on 20.x, flagged off on 22.x — and + * this project's declared floor is 22.9. On those runtimes a missing better-sqlite3 is not a slower + * server, it is no server. + * + * This is not theoretical: `npm ci` on a machine that declines to run install scripts reported + * success, silently left no better-sqlite3 on disk, and every test that spawns the server failed + * with "server did not boot". Preflight has to notice and repair that. + */ +test('an optional dependency counts as required where node:sqlite is unavailable', () => { + const pkg = { dependencies: { express: '^4' }, optionalDependencies: { 'better-sqlite3': '12.9.0' } }; + + const withBuiltin = preflight.requiredDeps(pkg, true); + assert.deepEqual(withBuiltin, ['express'], + 'on Node 24 the native driver is genuinely optional — the built-in can serve'); + + const withoutBuiltin = preflight.requiredDeps(pkg, false); + assert.deepEqual(withoutBuiltin.sort(), ['better-sqlite3', 'express'], + 'on Node 20/22 a missing native driver means the server cannot start, so repair it'); +}); + +test('requiredDeps copes with a package.json missing either section', () => { + assert.deepEqual(preflight.requiredDeps({}, false), []); + assert.deepEqual(preflight.requiredDeps({ optionalDependencies: { a: '1' } }, false), ['a']); + assert.deepEqual(preflight.requiredDeps({ dependencies: { b: '1' } }, false), ['b']); +});