screentinker/server/test/sqlite-driver.test.js
ScreenTinker 1bb24e7604 Choose the SQLite driver at runtime, and ship the FFmpeg licence with the binaries
TWO CHANGES, together because they touch the same packager hunks.

1. THE DRIVER.

The BrightSign package used to be MANUFACTURED. scripts/build-server-zip.sh dropped
better-sqlite3 from package.json and then installed db/sqlite-compat.js into
node_modules under that name, so every require resolved to the façade. It worked —
and it shipped a database layer that no test had ever executed. That is the same
shape as the TELEMETRY_COLLECTOR TDZ crash that took production down while 1676
tests and four CI jobs were green: a build-time rewrite cannot be tested by the
build that performs it.

db/sqlite-driver.js now decides at runtime: the native driver when it loads, the
node:sqlite façade otherwise. One artifact, one code path, and — the point — both
branches reachable from a test. ST_SQLITE_DRIVER=node runs the entire suite the way
a player runs it, and a new CI job does exactly that on Node 24 with --omit=optional
so the fallback is reached the same way it is on hardware, not by an env var alone.

better-sqlite3 becomes an optionalDependency, so a host with no compiler installs
cleanly and falls back rather than failing. preflight-deps stops trying to rebuild a
native module on a host that has no toolchain and a working built-in driver — on a
player that was a five-minute node-gyp failure ending in a server that never started.
Asking for the native driver BY NAME (ST_SQLITE_DRIVER=better-sqlite3) still fails
loudly, because a production box that has lost its native module is broken and should
say so rather than quietly running something else.

⚠️ NODE 24 IN PRACTICE. node:sqlite is unflagged only from 23.4; on the 22.x line it
needs --experimental-sqlite and on 20.x it does not exist. So the code probes rather
than comparing versions, the player package pins engines >=24, and the built-in cases
skip on the Node 20 CI job rather than failing there.

Verified on Node 24, both drivers, full suite:
  better-sqlite3   1762 pass / 0 fail
  node:sqlite      1762 pass / 0 fail
and the built payload resolves node:sqlite with no better-sqlite3 present at all.

2. THE LICENCE.

The ffprobe/ffmpeg binaries added in the previous commit are LGPL 2.1 and statically
linked, so the licence text has to travel WITH them — a link on a website is not the
copy the licence asks to accompany the work. The packager now copies
COPYING.LGPLv2.1 and a build README into bin/, and refuses to build if the licence is
missing. legal/third-party.html gains an LGPL section with the written offer required
by section 6 for static linking, and the exact configure line.

It also drops Sharp, which that page still listed although #263 removed it, and names
what actually does the image work now (jimp, @jsquash/webp, @jsquash/avif).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 20:57:05 -05:00

149 lines
7.5 KiB
JavaScript

'use strict';
/*
* The driver choice, and the thing that choice is supposed to protect: that BOTH drivers behave the
* same way for the SQL this server actually issues.
*
* ⚠️ WHY THIS FILE EXISTS AT ALL. The player package used to be manufactured by the packager, which
* dropped better-sqlite3 and installed db/sqlite-compat.js into node_modules under that name. The
* result was an artifact whose database layer no test had ever run — the same shape as the
* TELEMETRY_COLLECTOR crash that took production down with 1676 tests green. Choosing at runtime is
* only an improvement if the second choice is exercised, so these run the built-in driver on an
* ordinary developer machine via ST_SQLITE_DRIVER=node.
*
* Each case runs in a CHILD PROCESS. The driver decides once at require time and caches on the
* module registry, so two selections cannot coexist in one process, and clearing require.cache would
* leave the previous native binding loaded anyway.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const DRIVER = path.join(__dirname, '..', 'db', 'sqlite-driver.js');
/*
* ⚠️ NODE 24 IN PRACTICE. node:sqlite is unflagged only from Node 23.4; on 22.x it needs
* --experimental-sqlite and on 20.x it does not exist. CI currently runs the suite on Node 20, so
* the built-in-driver cases SKIP there rather than fail - a skip says "not checked here", which is
* true, where a failure would say "broken", which is not.
*
* The fallback is genuinely exercised by the separate Node 24 CI job that runs this whole suite with
* ST_SQLITE_DRIVER=node. If that job is ever dropped, these tests go quiet everywhere and the player's
* database layer is untested again - which is the exact hole this file was written to close.
*/
const NO_BUILTIN = (() => { try { require('node:sqlite'); return false; }
catch { return `node:sqlite unavailable on ${process.version}`; } })();
/*
* Whether the NATIVE module is usable under this exact Node. Not the same question as "is it
* installed": better-sqlite3 loads its binding lazily, so a node_modules built for another ABI
* requires fine and dies on first use. A tree built by Node 24 and then run under Node 20 is the
* everyday case on a developer machine, and "the native driver is preferred WHEN IT WORKS" is
* untestable there - so that case skips rather than reporting a bug that is not in the code.
*/
const NO_NATIVE = (() => {
try { const D = require('better-sqlite3'); new D(':memory:').close(); return false; }
catch (e) { return `better-sqlite3 unusable on ${process.version}: ${String(e.message).split('\n')[0]}`; }
})();
function inChild(code, env) {
return execFileSync(process.execPath, ['-e', code], {
encoding: 'utf8',
timeout: 30000,
env: Object.assign({}, process.env, env || {}),
}).trim();
}
test('by default it picks the native driver when the native driver works', { skip: NO_NATIVE }, () => {
const out = inChild(`console.log(require(${JSON.stringify(DRIVER)}).driverName)`, { ST_SQLITE_DRIVER: '' });
assert.equal(out, 'better-sqlite3');
});
test('ST_SQLITE_DRIVER=node selects the built-in one', { skip: NO_BUILTIN }, () => {
// This is the switch CI uses to run the whole suite the way a player runs it.
const out = inChild(`console.log(require(${JSON.stringify(DRIVER)}).driverName)`, { ST_SQLITE_DRIVER: 'node' });
assert.equal(out, 'node:sqlite');
});
test('asking for the native driver by name fails loudly rather than downgrading', () => {
/*
* A production server that has lost its native module should not quietly continue on a different
* driver: the install is broken and someone needs to know. Simulated by making better-sqlite3
* unresolvable in a throwaway directory rather than by touching the real node_modules.
*/
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stdrv-'));
const stub = path.join(dir, 'driver-copy.js');
fs.copyFileSync(DRIVER, stub);
fs.copyFileSync(path.join(__dirname, '..', 'db', 'sqlite-compat.js'), path.join(dir, 'sqlite-compat.js'));
let threw = null;
try {
inChild(`require(${JSON.stringify(stub)})`, { ST_SQLITE_DRIVER: 'better-sqlite3' });
} catch (e) {
threw = String(e.stderr || e.message);
}
assert.ok(threw, 'requesting an unusable native driver should throw');
assert.match(threw, /ST_SQLITE_DRIVER=better-sqlite3 was requested but it is not usable/);
fs.rmSync(dir, { recursive: true, force: true });
});
test('a missing native module falls back instead of failing', { skip: NO_BUILTIN }, () => {
// The player case: no compiler, no prebuild, no node-gyp — and the server still has to start.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stdrv-'));
fs.copyFileSync(DRIVER, path.join(dir, 'driver-copy.js'));
fs.copyFileSync(path.join(__dirname, '..', 'db', 'sqlite-compat.js'), path.join(dir, 'sqlite-compat.js'));
const out = inChild(
`console.log(require(${JSON.stringify(path.join(dir, 'driver-copy.js'))}).driverName)`,
{ ST_SQLITE_DRIVER: '' });
assert.equal(out, 'node:sqlite');
fs.rmSync(dir, { recursive: true, force: true });
});
test('THE POINT: both drivers give the same answers for the SQL this server issues', { skip: NO_BUILTIN }, () => {
/*
* Not a smoke test of "it opens". These are the shapes the server relies on at its 1501 prepare()
* call sites - named parameters, .get/.all/.run, lastInsertRowid, changes, and the three methods
* the façade has to provide itself (.pragma, .transaction, .pluck).
*/
const program = (dbPath) => `
const { Database } = require(${JSON.stringify(DRIVER)});
const db = new Database(${JSON.stringify(dbPath)});
db.pragma('journal_mode = WAL');
db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, n INTEGER)');
const ins = db.prepare('INSERT INTO t (name, n) VALUES (@name, @n)');
const r1 = ins.run({ name: 'a', n: 1 });
ins.run({ name: 'b', n: 2 });
const tx = db.transaction((rows) => { for (const row of rows) ins.run(row); });
tx([{ name: 'c', n: 3 }, { name: 'd', n: 4 }]);
const out = {
lastInsertRowid: Number(r1.lastInsertRowid),
changes: db.prepare('UPDATE t SET n = n + 1 WHERE name = ?').run('a').changes,
one: db.prepare('SELECT name, n FROM t WHERE name = ?').get('b'),
all: db.prepare('SELECT name FROM t ORDER BY name').all().map((r) => r.name),
plucked: db.prepare('SELECT n FROM t ORDER BY n').pluck().all(),
count: db.prepare('SELECT COUNT(*) AS c FROM t').get().c,
missing: db.prepare('SELECT name FROM t WHERE name = ?').get('nope') === undefined,
};
db.close();
console.log(JSON.stringify(out));
`;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'stdrv-cmp-'));
const native = JSON.parse(inChild(program(path.join(tmp, 'native.db')), { ST_SQLITE_DRIVER: 'better-sqlite3' }));
const builtin = JSON.parse(inChild(program(path.join(tmp, 'builtin.db')), { ST_SQLITE_DRIVER: 'node' }));
assert.deepEqual(builtin, native,
'the built-in driver must answer exactly as the native one does, or the player runs different software');
// And the values are what they should be, so two identically-wrong drivers cannot pass.
assert.deepEqual(native.all, ['a', 'b', 'c', 'd']);
assert.deepEqual(native.plucked, [2, 2, 3, 4]);
assert.equal(native.count, 4);
assert.equal(native.changes, 1);
assert.equal(native.missing, true);
fs.rmSync(tmp, { recursive: true, force: true });
});