From 2892d01b2406ec58f774198248b48a71dfa7975c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 10 Aug 2026 16:56:58 -0500 Subject: [PATCH] feat(modules): replay module schema fragments after core's Phase 2, PR 3 of docs/website/MODULE_SYSTEM.md 2.7. ensureSchema() now replays every installed module's schema fragment immediately after core's schema.sql, per MODULE_API.md 2.6. The work splits across two files on the line of whether a database is needed to know the answer. loader.js VALIDATES a fragment at load time, before anything is mounted, because every rule 2.6 states about the SQL is knowable by reading it; a module that breaks one never mounts (4.4, left column). modules/schema.js EXECUTES it, so the only failures there are the ones the database alone could report, and those are post-mount and answer 503 (4.4, right column). Validation is a leading-verb allowlist -- CREATE, ALTER, INSERT, UPDATE, the four core's own schema.sql uses -- rather than the DROP denylist 2.6 words it as. A fragment is replayed on every boot, so TRUNCATE and DELETE would empty a table at each restart and RENAME would fail at the second one; a denylist only ever bans what somebody thought of. A CREATE TABLE missing IF NOT EXISTS is rejected for the same reason: it works once and fails every boot after, which presents to an operator as a module that broke on restart. The splitter moves to utils/sqlStatements.js so core's schema and a fragment are split by literally the same code, which is what 2.6 promises. It is its own file rather than an export of utils/db.js because the loader validates fragments at require time and must not drag the mariadb pool into app.js's require chain. The replay sits outside ensureSchema's wait-for-the-database retry loop: a fragment that throws is one module's failure, not a signal the database is coming up, and retrying core's whole schema nine more times over one module's bad SQL would turn a 503'd module into a two-minute boot. Found while wiring it: db/seed.js calls ensureSchema() standalone for `npm run seed`, without ever requiring app.js, so the loader has not scanned and fragments()'s 7.6 throw would have broken seeding outright. The replay asks isLoaded() and logs the skip rather than swallowing it -- a booting server quietly getting no module tables is the thing 7.6 exists to prevent. Verification: - 856 server tests pass, 14 new. moduleSchema.test.js injects the query fn, so the exact statements and their order are asserted with the pool at a dead port like every other suite. - routes.manifest.json and routes.guards.json diffs are zero lines, 229 routes -- the phase 2 exit criterion. swagger-output.json regenerates byte-identical. - Run for real against the local MariaDB with two fixture modules: a good fragment created its table, applied its ALTER and seeded its row; a fragment whose SQL passes validation but the server rejects (`id NOTATYPE`) marked only that module startup_failed, its route answering 503 while the other answered 200; a second ensureSchema on the same database was a clean no-op. Co-Authored-By: Claude --- server/src/modules/loader.js | 91 ++++++++++- server/src/modules/schema.js | 84 ++++++++++ server/src/utils/db.js | 34 ++-- server/src/utils/sqlStatements.js | 37 +++++ server/test/moduleLoader.test.js | 71 ++++++++- server/test/moduleSchema.test.js | 247 ++++++++++++++++++++++++++++++ 6 files changed, 541 insertions(+), 23 deletions(-) create mode 100644 server/src/modules/schema.js create mode 100644 server/src/utils/sqlStatements.js create mode 100644 server/test/moduleSchema.test.js diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 0fc7e6f..6b1d958 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -18,16 +18,22 @@ // boot (§4.4). // // What is deliberately NOT here yet, each landing with the PR that first calls -// it (§2.7): schema-fragment replay (PR 3), the three de-entanglement registries -// (PR 4), boot/shutdown hook dispatch and the `installed_modules` reconcile -// (PR 5), GET /api/v1/public/modules and the client chunk's static mount -// (PRs 6-7). Until PR 5 the state a module carries is in memory only. +// it (§2.7): the three de-entanglement registries (PR 4), boot/shutdown hook +// dispatch and the `installed_modules` reconcile (PR 5), GET +// /api/v1/public/modules and the client chunk's static mount (PRs 6-7). Until +// PR 5 the state a module carries is in memory only. +// +// PR 3 added the fragment half of the schema story: this file VALIDATES a +// fragment (statement by statement, at load time, before anything is mounted) +// and publishes it through `fragments()`. Replaying it needs a database, so it +// belongs to modules/schema.js, which utils/db.js calls after core's schema. const fs = require('fs') const path = require('path') const { MODULE_API_VERSION } = require('./version') const semver = require('./semver') +const { splitStatements } = require('../utils/sqlStatements') const log = require('../utils/logger')('modules') @@ -193,10 +199,52 @@ function coreTableNames() { return coreTables } -/** Every table name a schema fragment declares. Throws if the file is unreadable. */ +// The only leading verbs a fragment may use — an allowlist, not a DROP denylist. +// +// §2.6 bans `DROP`, but a denylist only ever bans what somebody thought of, and +// core's own schema.sql needs exactly four verbs: CREATE, ALTER, INSERT, UPDATE. +// Anything else in a file that is REPLAYED ON EVERY BOOT is a mistake worth +// failing on — TRUNCATE and DELETE would empty a table every restart, RENAME +// would break on the second one, and GRANT/SET/USE are core's business, not a +// module's. CREATE covers CREATE INDEX as well as CREATE TABLE. +// +// This is a leading-verb check and says so: `ALTER TABLE x DROP COLUMN y` passes +// it. Catching that needs a SQL parser, which is a large dependency to take on +// for a rule whose real job is stopping the obvious foot-gun early. +const ALLOWED_VERBS = new Set(['CREATE', 'ALTER', 'INSERT', 'UPDATE']) + +const CREATE_TABLE_ANY = /^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?/i +const CREATE_TABLE_GUARDED = /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+/i + +/** + * Read and validate a module's schema fragment; return every table it declares. + * + * Validation happens HERE, at load time, and not in modules/schema.js where the + * fragment is replayed, because every rule §2.6 states is knowable by reading + * the file — no database required. Failing at load means a module with a bad + * fragment never mounts at all (§4.4's first column: routes and nav simply + * absent), rather than mounting, 503ing, and leaving whatever its fragment did + * manage to execute behind it. + * + * Throws if the file is unreadable or breaks a rule. + */ function tablesOf(dir, manifest) { if (!manifest.schema) return new Set() - const sql = fs.readFileSync(path.join(dir, manifest.schema), 'utf8') + const file = path.join(dir, manifest.schema) + const sql = fs.readFileSync(file, 'utf8') + + for (const statement of splitStatements(sql)) { + const verb = (statement.match(/^\w+/) || [''])[0].toUpperCase() + if (!ALLOWED_VERBS.has(verb)) { + throw new Error(`schema fragment statement starts with "${verb}" (allowed: ${[...ALLOWED_VERBS].join(', ')})`) + } + // A bare CREATE TABLE succeeds exactly once and fails every boot after it, + // which presents as a module that worked until the first restart. + if (CREATE_TABLE_ANY.test(statement) && !CREATE_TABLE_GUARDED.test(statement)) { + throw new Error('schema fragment has a CREATE TABLE without IF NOT EXISTS') + } + } + return new Set([...sql.matchAll(CREATE_TABLE)].map((m) => m[1].toLowerCase())) } @@ -448,6 +496,18 @@ function assertLoaded(caller) { if (!loaded) throw new Error(`modules.${caller}() before modules.load()`) } +/** + * Has load() run in this process? + * + * The one legitimate reason to ask instead of just calling an accessor: a + * process that never required app.js and so has no module list to be wrong + * about. `npm run seed` (db/seed.js) is exactly that — it calls ensureSchema() + * standalone, and the fragment replay has to be able to tell "this is the seed + * script" from "the server booted and something is mis-ordered", which is the + * distinction §7.6's throw exists to preserve everywhere else. + */ +const isLoaded = () => loaded + /** * Every module found on the volume, loaded or failed, in scan order. * @@ -467,7 +527,24 @@ function list() { })) } +/** + * Every schema fragment waiting to be replayed, in scan order. + * + * `registered` only: a module that failed validation must not get its tables + * created (it is not going to run), and one already `started` has had them. The + * absolute path is resolved here rather than handed out as a manifest-relative + * name, so the replay never has to know how a module directory is laid out. + * + * @returns {{id: string, file: string}[]} + */ +function fragments() { + assertLoaded('fragments') + return [...modules.values()] + .filter((r) => r.state === 'registered' && r.manifest.schema) + .map((r) => ({ id: r.id, file: path.join(r.dir, r.manifest.schema) })) +} + /** Absolute path of the modules directory. */ const dir = () => MODULES_DIR -module.exports = { load, list, setState, dir } +module.exports = { load, list, setState, fragments, isLoaded, dir } diff --git a/server/src/modules/schema.js b/server/src/modules/schema.js new file mode 100644 index 0000000..13b59d0 --- /dev/null +++ b/server/src/modules/schema.js @@ -0,0 +1,84 @@ +// ── Module schema fragment replay ────────────────────────────────────────── +// +// Phase 2, PR 3 of docs/website/MODULE_SYSTEM.md §2.7. Normative contract: +// docs/website/MODULE_API.md §2.6 (fragments) and §4.4 (failure is a state). +// +// utils/db.js calls replayFragments() once, immediately after core's schema.sql +// is in place and before seedDefaults(), so that by the time a module's onBoot +// runs (PR 5) its tables exist. +// +// The split of responsibility with loader.js is worth stating, because it is the +// reason there are two files: +// +// loader.js VALIDATES a fragment — at load time, with no database, before +// anything is mounted. Every rule §2.6 states about the SQL is +// knowable by reading it, so a fragment that breaks one costs the +// module its mount entirely (§4.4, first column). +// schema.js EXECUTES it. Only reachable failures live here: the database +// rejecting a statement it could not have known was bad. Those are +// post-mount, so they 503 (§4.4, second column). +// +// The property this file exists to keep: **a fragment that fails takes down its +// own module and nothing else.** Not core's boot, not another module's tables. + +const fs = require('fs') + +const { splitStatements } = require('../utils/sqlStatements') + +const log = require('../utils/logger')('modules') + +/** + * Replay every installed module's schema fragment, in scan order. + * + * Never throws. A module whose fragment fails is moved to `startup_failed` with + * the database's own message as the reason, its routes answer 503 through the + * dispatch guard the loader already mounted, and the next module is replayed as + * if nothing happened. + * + * Partial application is accepted rather than compensated for: MariaDB commits + * each DDL statement implicitly, so a fragment failing at statement three has + * already created the first two tables and no wrapping transaction could undo + * them. Since every statement is required to be idempotent (§2.6), the fix is + * for the operator to correct the fragment and reboot — the surviving tables are + * re-CREATE-IF-NOT-EXISTSed harmlessly and the replay carries on past them. + * + * @param {object} [deps] injection seam for tests — the whole point of this + * function taking arguments at all, since the server suite runs with the pool + * pointed at a dead port. + * @param {(sql: string) => Promise} [deps.query] + * @param {object} [deps.modules] the loader + */ +async function replayFragments({ query, modules } = {}) { + /* eslint-disable global-require */ + const run = query || require('../utils/db').query + const loader = modules || require('./loader') + /* eslint-enable global-require */ + + // Not an error: `npm run seed` calls ensureSchema() without ever requiring + // app.js, so no scan has happened and there is genuinely nothing to replay. + // Logged rather than silently skipped — the one thing that must not happen is + // a booting server quietly getting no module tables (§7.6). + if (!loader.isLoaded()) { + log.info('no module scan in this process — skipping schema fragment replay') + return + } + + for (const { id, file } of loader.fragments()) { + try { + const statements = splitStatements(fs.readFileSync(file, 'utf8')) + for (const statement of statements) { + // Serially, and awaited: a fragment's ALTER TABLE routinely depends on + // the CREATE TABLE above it. + await run(statement) + } + log.info(`schema ensured for module "${id}"`, { statements: statements.length }) + } catch (err) { + loader.setState(id, 'startup_failed', err.message) + log.error(`module "${id}" schema fragment failed — its routes will answer 503`, { + reason: err.message, + }) + } + } +} + +module.exports = { replayFragments } diff --git a/server/src/utils/db.js b/server/src/utils/db.js index 362d41d..713784b 100644 --- a/server/src/utils/db.js +++ b/server/src/utils/db.js @@ -4,6 +4,7 @@ const mariadb = require('mariadb') require('dotenv').config() const log = require('./logger')('db') +const { splitStatements } = require('./sqlStatements') const pool = mariadb.createPool({ host: process.env.DB_HOST || '127.0.0.1', @@ -44,28 +45,31 @@ const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql') /** * Create tables if they do not exist. Idempotent. Retries while the DB is still * coming up (important under docker-compose even with a healthcheck). + * + * Once core's schema is in place, every installed module's schema fragment is + * replayed after it (MODULE_API.md §2.6). That step is deliberately OUTSIDE the + * retry loop: a fragment that throws is that module's failure, not a signal the + * database is still coming up, and retrying core's whole schema nine more times + * because one module shipped bad SQL would turn a 503'd module into a two-minute + * boot. It is also why this file knows nothing about modules beyond the one call + * below — the discovery, splitting and per-module failure handling all live in + * modules/schema.js, required lazily so that requiring the pool never drags the + * loader in with it. */ async function ensureSchema({ retries = 10, delayMs = 2000 } = {}) { + await ensureCoreSchema({ retries, delayMs }) + // eslint-disable-next-line global-require + await require('../modules/schema').replayFragments() +} + +/** Core's own schema.sql, with the wait-for-the-database retry. */ +async function ensureCoreSchema({ retries, delayMs }) { for (let attempt = 1; attempt <= retries; attempt++) { try { const conn = await pool.getConnection() try { const sql = fs.readFileSync(SCHEMA_PATH, 'utf8') - // Strip `--` comments (full-line AND trailing) before splitting — so a - // leading comment block doesn't get glued onto the statement that follows - // it, and a `;` inside a trailing comment can't chop a statement in half. - // Safe because the schema never puts `--` inside a string literal. - const statements = sql - .split('\n') - .map((line) => { - const i = line.indexOf('--') - return i === -1 ? line : line.slice(0, i) - }) - .join('\n') - .split(';') - .map((s) => s.trim()) - .filter((s) => s.length > 0) - for (const statement of statements) { + for (const statement of splitStatements(sql)) { await conn.query(statement) } log.info('schema ensured') diff --git a/server/src/utils/sqlStatements.js b/server/src/utils/sqlStatements.js new file mode 100644 index 0000000..9c86f6c --- /dev/null +++ b/server/src/utils/sqlStatements.js @@ -0,0 +1,37 @@ +// ── Splitting a .sql file into statements ────────────────────────────────── +// +// Extracted from utils/db.js so that core's schema.sql and a module's schema +// fragment are split by literally the same code. MODULE_API.md §2.6 promises a +// fragment is replayed "statement by statement, split the same way" — with two +// copies of this that promise would hold only until one of them was edited. +// +// It lives in its own file rather than being exported from utils/db.js because +// modules/loader.js validates fragments at require time and must not pull the +// mariadb pool into app.js's require chain to do it. + +/** + * Split a .sql file into individual statements. + * + * Strips `--` comments (full-line AND trailing) before splitting — so a leading + * comment block doesn't get glued onto the statement that follows it, and a `;` + * inside a trailing comment can't chop a statement in half. Safe because neither + * core's schema nor a conforming fragment puts `--` inside a string literal + * (§2.6 states that as a rule a fragment must follow). + * + * @param {string} sql + * @returns {string[]} non-empty, trimmed statements in file order + */ +function splitStatements(sql) { + return sql + .split('\n') + .map((line) => { + const i = line.indexOf('--') + return i === -1 ? line : line.slice(0, i) + }) + .join('\n') + .split(';') + .map((s) => s.trim()) + .filter((s) => s.length > 0) +} + +module.exports = { splitStatements } diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js index ccc40ae..e6212f0 100644 --- a/server/test/moduleLoader.test.js +++ b/server/test/moduleLoader.test.js @@ -284,7 +284,7 @@ test('registering the same thing twice is an error, not a silent overwrite', () assert.match(stateOf(loader, 'twice').reason, /registerRoutes\(\) called twice/) }) -// ── Schema fragment validation (the replay itself is PR 3) ───────────────── +// ── Schema fragment validation (the replay itself is moduleSchema.test.js) ── test('a schema fragment declaring a core table is rejected', () => { writeModule('thief', { schema: 'CREATE TABLE IF NOT EXISTS users (id INT);' }) @@ -332,6 +332,75 @@ test('a declared purge file that is not there is rejected', () => { assert.match(stateOf(loader, 'gone').reason, /purge file "purge\.sql" is missing/) }) +test('a fragment may only use the four verbs core\'s own schema.sql uses', () => { + // An allowlist rather than a DROP denylist. §2.6 bans DROP, but this file is + // REPLAYED ON EVERY BOOT, so TRUNCATE and DELETE would empty a table at every + // restart and RENAME would fail at the second one — a denylist only ever bans + // what somebody thought of. + for (const [id, sql, verb] of [ + ['dropper', 'DROP TABLE dropper_x;', 'DROP'], + ['nuker', 'TRUNCATE TABLE nuker_x;', 'TRUNCATE'], + ['wiper', 'DELETE FROM wiper_x;', 'DELETE'], + ['granter', 'GRANT ALL ON *.* TO app;', 'GRANT'], + ]) { + writeModule(id, { schema: sql }) + const reason = stateOf(freshLoader(tmpRoot), id).reason + assert.match(reason, new RegExp(`starts with "${verb}"`)) + } +}) + +test('a fragment may INSERT and UPDATE its own seed data', () => { + // Core's schema.sql does both (INSERT IGNORE INTO settings, one UPDATE), so a + // module that seeds a lookup table the same way must not be rejected. + writeModule('seeder', { + schema: [ + 'CREATE TABLE IF NOT EXISTS seeder_kinds (id INT PRIMARY KEY, label VARCHAR(32));', + "INSERT IGNORE INTO seeder_kinds (id, label) VALUES (1, 'first');", + "UPDATE seeder_kinds SET label = 'first' WHERE id = 1;", + ].join('\n'), + }) + assert.equal(stateOf(freshLoader(tmpRoot), 'seeder').state, 'registered') +}) + +test('a CREATE TABLE without IF NOT EXISTS is rejected', () => { + // It succeeds exactly once and fails every boot after it, which presents as a + // module that worked until the first restart — the worst kind of bug to ship + // to an operator, and free to catch by reading the file. + writeModule('once', { schema: 'CREATE TABLE once_x (id INT);' }) + assert.match(stateOf(freshLoader(tmpRoot), 'once').reason, /CREATE TABLE without IF NOT EXISTS/) +}) + +test('a fragment carrying an unreadable file fails the module, not the boot', () => { + writeModule('missing', { schema: 'CREATE TABLE IF NOT EXISTS missing_x (id INT);' }) + fs.unlinkSync(path.join(tmpRoot, 'missing', 'schema.sql')) + writeModule('fine', oneRoute('/ok')) + + const loader = freshLoader(tmpRoot) + assert.equal(stateOf(loader, 'missing').state, 'startup_failed') + assert.equal(stateOf(loader, 'fine').state, 'registered') +}) + +test('fragments() lists only registered modules that have one', () => { + writeModule('withdb', { schema: 'CREATE TABLE IF NOT EXISTS withdb_x (id INT);' }) + writeModule('nodb', oneRoute('/plain')) + writeModule('broken', { schema: 'CREATE TABLE IF NOT EXISTS not_mine (id INT);' }) + + const loader = freshLoader(tmpRoot) + const frags = loader.fragments() + + assert.deepEqual(frags.map((f) => f.id), ['withdb']) + // An absolute path, so the replay never has to know how a module dir is laid out. + assert.equal(frags[0].file, path.join(tmpRoot, 'withdb', 'schema.sql')) +}) + +test('fragments() before load() throws, like every other accessor', () => { + process.env.MODULES_DIR = tmpRoot + delete require.cache[require.resolve('../src/modules/loader')] + // eslint-disable-next-line global-require + const loader = require('../src/modules/loader') + assert.throws(() => loader.fragments(), /modules\.fragments\(\) before modules\.load\(\)/) +}) + // ── Mounting and the dispatch guard ──────────────────────────────────────── test('a registered module answers on its prefix; a failed one is simply absent', async () => { diff --git a/server/test/moduleSchema.test.js b/server/test/moduleSchema.test.js new file mode 100644 index 0000000..66e714f --- /dev/null +++ b/server/test/moduleSchema.test.js @@ -0,0 +1,247 @@ +// ── Replaying module schema fragments ────────────────────────────────────── +// +// Phase 2, PR 3. The contract is MODULE_API.md §2.6 (a fragment is replayed by +// the same ensureSchema() that replays core's, statement by statement, split the +// same way) and §4.4 (a failure after mounting is a state, not a crash). +// +// The property under test throughout, as in moduleLoader.test.js: **the failing +// module fails alone.** A fragment that blows up must cost its own module its +// routes and nothing else — not core's boot, not the next module's tables. +// +// No database is involved. `replayFragments` takes its `query` as an injectable +// dependency precisely so this suite can assert on the exact statements that +// would have been executed, in order, with the pool pointed at a dead port like +// every other suite here. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const fs = require('fs') +const os = require('os') +const path = require('path') + +const { test, beforeEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const express = require('express') + +const db = require('../src/utils/db') +const { replayFragments } = require('../src/modules/schema') +const { splitStatements } = require('../src/utils/sqlStatements') +const { startApp } = require('./_helper') + +after(() => db.close()) + +let tmpRoot + +const emptyTiers = () => ({ + public: express.Router(), + admin: express.Router(), + player: express.Router(), +}) + +function freshLoader(dir, tiers = emptyTiers()) { + process.env.MODULES_DIR = dir + delete require.cache[require.resolve('../src/modules/loader')] + // eslint-disable-next-line global-require + const loader = require('../src/modules/loader') + loader.load(tiers) + return loader +} + +/** A module with a valid fragment, and optionally a route to watch 503 later. */ +function writeModule(id, { schema, mounts } = {}) { + const dir = path.join(tmpRoot, id) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({ + id, + name: id, + version: '1.0.0', + coreApi: '^1.0.0', + ...(schema === undefined ? {} : { schema: 'schema.sql', purge: 'purge.sql' }), + ...(mounts === undefined ? {} : { mounts, server: 'index.js' }), + })) + if (schema !== undefined) { + fs.writeFileSync(path.join(dir, 'schema.sql'), schema) + fs.writeFileSync(path.join(dir, 'purge.sql'), `DROP TABLE IF EXISTS ${id}_x;`) + } + if (mounts !== undefined) { + const [prefix] = mounts.public + fs.writeFileSync(path.join(dir, 'index.js'), `module.exports = (ctx, api) => { + const r = ctx.express.Router() + r.get('/', (req, res) => res.json({ ok: true })) + api.registerRoutes({ public: { '${prefix}': r } }) + }`) + } + return dir +} + +/** A query fn that records what it was asked to run, and can be told to fail. */ +function recorder(failOn = null) { + const ran = [] + return { + ran, + query: async (sql) => { + ran.push(sql) + if (failOn && sql.includes(failOn)) throw new Error(`ER_PARSE_ERROR: near "${failOn}"`) + return [] + }, + } +} + +const stateOf = (loader, id) => loader.list().find((m) => m.id === id) + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-schema-')) +}) + +// ── The happy path ───────────────────────────────────────────────────────── + +test('a fragment is replayed statement by statement, in file order', async () => { + writeModule('alpha', { + schema: [ + 'CREATE TABLE IF NOT EXISTS alpha_a (id INT);', + 'CREATE TABLE IF NOT EXISTS alpha_b (id INT);', + 'ALTER TABLE alpha_a ADD COLUMN IF NOT EXISTS name VARCHAR(64);', + ].join('\n'), + }) + const loader = freshLoader(tmpRoot) + const rec = recorder() + + await replayFragments({ query: rec.query, modules: loader }) + + assert.equal(rec.ran.length, 3) + // Order is load-bearing, not incidental: the ALTER depends on the CREATE above + // it, which is why the replay awaits each statement rather than Promise.all. + assert.match(rec.ran[0], /alpha_a/) + assert.match(rec.ran[1], /alpha_b/) + assert.match(rec.ran[2], /^ALTER TABLE alpha_a/) + assert.equal(stateOf(loader, 'alpha').state, 'registered') +}) + +test('a module with no fragment is skipped, not replayed as empty', async () => { + writeModule('nodb') + const rec = recorder() + + await replayFragments({ query: rec.query, modules: freshLoader(tmpRoot) }) + + assert.deepEqual(rec.ran, []) +}) + +test('fragments are split exactly the way core schema.sql is', async () => { + // §2.6's "split the same way" is a promise about shared code, so the thing + // worth asserting is that the module path produces what the shared splitter + // produces — including the trailing-comment case that would otherwise chop a + // statement in half at the `;` inside it. + const sql = [ + '-- a leading comment block', + '-- with two lines; and a semicolon in it', + 'CREATE TABLE IF NOT EXISTS beta_a (id INT); -- trailing; comment', + '', + 'CREATE TABLE IF NOT EXISTS beta_b (id INT);', + ].join('\n') + writeModule('beta', { schema: sql }) + const rec = recorder() + + await replayFragments({ query: rec.query, modules: freshLoader(tmpRoot) }) + + assert.deepEqual(rec.ran, splitStatements(sql)) + assert.equal(rec.ran.length, 2) +}) + +// ── Failure is a state ───────────────────────────────────────────────────── + +test('a fragment that throws fails its own module and no one else', async () => { + writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_x (id INT);' }) + writeModule('bbb', { schema: 'CREATE TABLE IF NOT EXISTS bbb_boom (id INT);' }) + writeModule('ccc', { schema: 'CREATE TABLE IF NOT EXISTS ccc_x (id INT);' }) + const loader = freshLoader(tmpRoot) + const rec = recorder('bbb_boom') + + // Never throws — this is called on the boot path, between core's schema and + // seedDefaults(), and one bad module must not stop the site coming up. + await replayFragments({ query: rec.query, modules: loader }) + + assert.equal(stateOf(loader, 'aaa').state, 'registered') + assert.equal(stateOf(loader, 'bbb').state, 'startup_failed') + assert.match(stateOf(loader, 'bbb').reason, /ER_PARSE_ERROR/) + // The one that matters: the module AFTER the failure still got its tables. + assert.equal(stateOf(loader, 'ccc').state, 'registered') + assert.equal(rec.ran.length, 3) +}) + +test('a fragment stops at its first failing statement', async () => { + writeModule('part', { + schema: [ + 'CREATE TABLE IF NOT EXISTS part_a (id INT);', + 'CREATE TABLE IF NOT EXISTS part_bad (id INT);', + 'CREATE TABLE IF NOT EXISTS part_c (id INT);', + ].join('\n'), + }) + const loader = freshLoader(tmpRoot) + const rec = recorder('part_bad') + + await replayFragments({ query: rec.query, modules: loader }) + + // Two attempted, the third never reached. The first table survives, and is + // accepted rather than compensated for: DDL self-commits in MariaDB, so no + // transaction could roll it back, and §2.6's idempotence rule is what makes + // re-running the corrected fragment safe. + assert.equal(rec.ran.length, 2) + assert.equal(stateOf(loader, 'part').state, 'startup_failed') +}) + +test('a module whose fragment failed keeps its URLs and answers 503', async () => { + // §4.4's right-hand column, now reachable for real rather than by a hand-moved + // state: schema replay is the first thing in the lifecycle that fails AFTER + // the routes are already mounted. + writeModule('svc', { + schema: 'CREATE TABLE IF NOT EXISTS svc_boom (id INT);', + mounts: { public: ['/widgets'] }, + }) + const tiers = emptyTiers() + const loader = freshLoader(tmpRoot, tiers) + const app = await startApp((a) => a.use('/public', tiers.public)) + + try { + assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200) + + await replayFragments({ query: recorder('svc_boom').query, modules: loader }) + + assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503) + } finally { + await app.close() + } +}) + +test('a module that failed validation is not replayed at all', async () => { + // It is never going to run, so creating its tables would leave an operator + // with rows belonging to a module that does not load. + writeModule('bad', { schema: 'CREATE TABLE IF NOT EXISTS not_prefixed (id INT);' }) + writeModule('good', { schema: 'CREATE TABLE IF NOT EXISTS good_x (id INT);' }) + const loader = freshLoader(tmpRoot) + const rec = recorder() + + await replayFragments({ query: rec.query, modules: loader }) + + assert.equal(stateOf(loader, 'bad').state, 'startup_failed') + assert.equal(rec.ran.length, 1) + assert.match(rec.ran[0], /good_x/) +}) + +// ── The seed script ──────────────────────────────────────────────────────── + +test('replay is skipped, not thrown, when no scan happened in this process', async () => { + // `npm run seed` (db/seed.js) calls ensureSchema() standalone without ever + // requiring app.js, so the loader never ran. Before this was handled it was + // fragments()'s §7.6 throw, which would have broken seeding outright. + process.env.MODULES_DIR = tmpRoot + delete require.cache[require.resolve('../src/modules/loader')] + // eslint-disable-next-line global-require + const loader = require('../src/modules/loader') + const rec = recorder() + + await replayFragments({ query: rec.query, modules: loader }) + + assert.deepEqual(rec.ran, []) + assert.equal(loader.isLoaded(), false) +})