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) +})