// ── 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) })