Files
website/server/test/moduleSchema.test.js
wtclaude 2892d01b24
All checks were successful
PR Checks / bot-install (pull_request) Successful in 22s
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 10m9s
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 <noreply@anthropic.com>
2026-08-10 16:56:58 -05:00

248 lines
9.6 KiB
JavaScript

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