screentinker/server/lib/preflight-deps.js
screentinker c436a44c89
Pin better-sqlite3 to 12.9.0 (was ^9.4.3) (#264)
Prepares for the Node 22 move by decoupling it from the database driver, so the
two upgrades land as independently reversible steps rather than one flag day.

9.6.0 cannot work on Node 22. It uses the raw V8 API (220 v8:: references, zero
napi_), so it is ABI-locked per Node major, and its GitHub release assets carry
prebuilds for ABI 108/115/120 only — nothing for Node 22's 127. Its install script
is `prebuild-install || node-gyp rebuild --release`, so on Node 22 it silently
falls through to compiling raw-V8 code against Node 22 headers. That is not just
slow: lib/preflight-deps.js rebuilds synchronously before the server listens, and
prod's systemd unit is TimeoutStartSec=90 with Restart=always, so a slow or failing
compile is an unbootable loop rather than the intended self-heal.

12.9.0 ships prebuilds for BOTH Node 20 (ABI 115) and Node 22 (127), so neither the
current runtime nor the target has to compile anything.

THE PIN IS EXACT ON PURPOSE — ^12.9.0 would defeat it. 12.10.0 dropped the Node 20
prebuild while still advertising "20.x" in engines, so a caret resolves to 12.11.x
and reintroduces the from-source compile on today's runtime. Verified against the
release assets per version:
    12.0.0 / 12.2.0 / 12.4.5 / 12.6.2 / 12.9.0   ABIs 115,127,...
    12.10.0 / 12.10.1 / 12.11.1                  ABIs 127,137,141,147 — no 115
The reasoning is recorded in preflight-deps.js, which is where anyone hitting the
matching failure will already be reading.

13.x was considered and rejected FOR NOW: it is the first N-API release, which ends
the per-major ABI problem for good (8 prebuilds keyed by platform, not ABI) and is
where we should eventually land — but engines is ">=22", so it cannot be adopted
while prod, alpha and CI all run Node 20. It is also three weeks old with three
patch releases, which is young for the one component that owns all the data.

No API changes to absorb: every major from 10 to 13 bumped only for dropping EOL
Node/Electron versions, so the ~1486 .prepare(), 46 .transaction() and 59 .pragma()
call sites are untouched.

Verified on Node 20: installed from a PREBUILT binary (no obj.target, so no
compilation), opens a real database, and the WAL path the #149 checkpointer depends
on still works — journal_mode=WAL engages on a file DB, pragma(...,{simple:false})
returns the expected shape, and a second connection from another handle reads and
runs wal_checkpoint(TRUNCATE). 1649/1649 tests pass.
2026-08-13 12:18:47 -05:00

191 lines
9.5 KiB
JavaScript

'use strict';
/*
* Make sure the dependencies this build needs are actually installed and loadable — BEFORE anything
* requires them.
*
* The normal upgrade path (scripts/upgrade.sh) runs `npm ci --omit=dev`, so this is not for the
* happy case. It is for the three ways a running box ends up with the wrong node_modules:
*
* ROLLBACK checking out an older tag to back out a bad release restores that tag's
* package.json but not its packages, so the server dies on a MODULE_NOT_FOUND for
* something the newer build had removed. That is a bad moment to be reading a
* stack trace: you are already rolling back because something else broke.
* NODE UPGRADE better-sqlite3 is a native module compiled against one ABI. Upgrading Node makes
* every boot fail with NODE_MODULE_VERSION mismatch, which reads like database
* corruption and is not.
*
* ⚠️ better-sqlite3 is pinned to EXACTLY 12.9.0, not a caret range, and the reason
* is invisible from package.json: 12.10.0 DROPPED the prebuilt binary for Node 20
* (ABI 115) while still advertising `"node": "20.x || ..."` in engines. So a caret
* resolves to 12.11.x, finds no prebuild on Node 20, and silently falls through to
* `node-gyp rebuild` — a from-source compile during install, and during the repair
* below. That matters here: this file rebuilds synchronously BEFORE the server
* listens, and prod's systemd unit has TimeoutStartSec=90 with Restart=always, so a
* slow or failing compile is a boot loop rather than a self-heal. 12.9.0 is the last
* version shipping prebuilds for BOTH Node 20 (115) and Node 22 (127), which is what
* lets the runtime move without the module having to compile at all.
* Re-check the release assets before widening the pin.
* HAND EDITS a `git checkout`, a partly-copied tree, an interrupted install.
*
* All three present as a server that will not start, with an error that names a file rather than
* the action needed. Detecting and repairing is a few seconds; diagnosing is an outage.
*
* ⚠️ Deliberately dependency-free — only Node builtins. Anything it required could be the very
* thing that is missing.
*
* Set ST_SKIP_DEP_PREFLIGHT=1 to turn it off (air-gapped hosts, or an operator who manages
* node_modules themselves and does not want a boot reaching for the network).
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
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 declared dependencies are not on disk. */
function missingDeps() {
let pkg;
try {
pkg = JSON.parse(fs.readFileSync(path.join(SERVER_DIR, 'package.json'), 'utf8'));
} catch {
return []; // no package.json is not our problem to diagnose
}
const declared = Object.keys(pkg.dependencies || {});
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; }
});
}
/**
* Is the native module loadable by THIS Node?
*
* Checked by actually loading it, because the failure is an ABI mismatch that no version string
* comparison catches reliably — a rebuild against the same major can still differ.
*/
function nativeModuleBroken() {
try {
/*
* ⚠️ CONSTRUCT one, do not merely require it.
*
* better-sqlite3's entry point is plain JavaScript and loads the compiled `.node` binding
* lazily, so `require()` alone SUCCEEDS under a Node whose ABI the binary was not built for —
* the first version of this check did exactly that and reported a broken install as healthy,
* verified against a real Node 18 / Node 20 mismatch. Opening an in-memory database is what
* actually pulls the binding in, and it touches no file.
*/
const Database = require('better-sqlite3');
new Database(':memory:').close();
return null;
} catch (e) {
const msg = String((e && e.message) || '');
if (/NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|was compiled against a different/i.test(msg)) return msg;
if (/Cannot find module/i.test(msg)) return msg;
// Anything else is a real error in the module, not an installation problem — let it surface
// later with its own stack rather than being masked by an npm run.
return null;
}
}
function run(args, label) {
console.log(`[preflight] ${label}: npm ${args.join(' ')}`);
execFileSync('npm', args, { cwd: SERVER_DIR, stdio: 'inherit', timeout: INSTALL_TIMEOUT_MS });
}
function fail(reason, hint) {
console.error(`[preflight] ${reason}`);
console.error(`[preflight] ${hint}`);
console.error('[preflight] Set ST_SKIP_DEP_PREFLIGHT=1 to boot without this check.');
process.exit(1);
}
function preflight() {
// Same spellings as every other boolean the server accepts, so an operator who writes `true`
// does not silently get a boot that reaches for the registry anyway.
if (['1', 'true', 'yes'].includes(String(process.env.ST_SKIP_DEP_PREFLIGHT || '').toLowerCase())) return;
const missing = missingDeps();
const nodeModulesAbsent = !fs.existsSync(NODE_MODULES);
if (missing.length || nodeModulesAbsent) {
const what = nodeModulesAbsent
? 'node_modules is missing'
: `${missing.length} dependency/dependencies missing: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? '…' : ''}`;
console.warn(`[preflight] ${what} — installing before start.`);
try {
/*
* `npm ci` when there is a lockfile and nothing installed: it is reproducible and it is what
* upgrade.sh uses. Otherwise `npm install`, because `ci` DELETES node_modules first and would
* throw away a working tree to fix one missing package.
*/
const hasLock = fs.existsSync(path.join(SERVER_DIR, 'package-lock.json'));
if (hasLock && nodeModulesAbsent) {
/*
* Nothing installed, so `ci` has nothing to destroy and gives a reproducible tree.
*
* `--omit=dev` ONLY when this is plainly a production boot. Applying it unconditionally
* meant a cold start on a developer machine installed 307 packages and left `npm test`
* broken — js-yaml, puppeteer-core and socket.io-client absent — which is the same class of
* surprise as the prune this file already warns about, arriving through the other branch of
* the same `if`.
*/
const prod = process.env.NODE_ENV === 'production';
run(prod ? ['ci', '--omit=dev', '--no-audit', '--no-fund'] : ['ci', '--no-audit', '--no-fund'], 'installing');
} else {
/*
* ⚠️ Install ONLY what is missing, by name, and never `--omit=dev` on a populated tree.
*
* `npm install --omit=dev` reconciles the whole tree, which PRUNES devDependencies — so
* merely starting the server deleted socket.io-client, puppeteer-core and js-yaml, and broke
* `npm test`. A review watched it happen. A boot-time repair that quietly removes packages
* is worse than the failure it fixes, so this touches nothing it was not asked to.
*
* `--no-save` because a server starting up has no business editing package.json.
*/
run(['install', '--no-save', '--no-audit', '--no-fund', ...missing], 'installing missing packages');
}
} catch (e) {
/*
* An install can fail because ANOTHER server started at the same moment and won the race —
* observed as `ENOTEMPTY … rename node_modules/fs-extra`. The tree is complete by the time we
* see the error, so exiting here killed a process that had nothing wrong with it. Re-check
* before giving up; only a genuinely incomplete tree is fatal.
*/
const afterFailure = missingDeps();
if (afterFailure.length) {
fail(`could not install dependencies: ${e && e.message}`,
'Run `npm ci --omit=dev` in the server directory, or check network access to the npm registry.');
}
console.warn(`[preflight] install reported an error but the tree is complete (${e && e.message}) — continuing.`);
}
const still = missingDeps();
if (still.length) {
fail(`still missing after install: ${still.join(', ')}`, 'Check the npm output above.');
}
console.log('[preflight] dependencies installed.');
}
const nativeProblem = nativeModuleBroken();
if (nativeProblem) {
console.warn(`[preflight] better-sqlite3 will not load under Node ${process.version} — rebuilding.`);
console.warn(`[preflight] ${nativeProblem.split('\n')[0]}`);
try {
run(['rebuild', 'better-sqlite3'], 'rebuilding native module');
} catch (e) {
fail(`could not rebuild better-sqlite3: ${e && e.message}`,
`Run \`npm rebuild better-sqlite3\` in the server directory. This usually means Node changed version (now ${process.version}) and the module needs recompiling; a build toolchain (python3, make, g++) must be present.`);
}
if (nativeModuleBroken()) {
fail('better-sqlite3 still will not load after a rebuild.',
'Delete server/node_modules and run `npm ci --omit=dev`.');
}
console.log('[preflight] native module rebuilt.');
}
}
module.exports = { preflight, missingDeps, nativeModuleBroken };