Files
website/server/test/moduleSchema.test.js
wtclaude f40dd765c3
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 49s
PR Checks / client-build (pull_request) Successful in 1m4s
PR Checks / server-tests (pull_request) Successful in 6m6s
fix(core): the last fixes' core half (W1 W2 W5 W19)
Stage 4 of docs/modules/rust/PLAN_LAST_FIXES.md, with the org lead's
2026-10-22 decisions.

W19: one module per site at boot, as at install. The first module
directory by name holds the site, whether or not it then loads; every
other is recorded startup_failed at the manifest stage, before a line of
its code is required, with a reason naming the module that holds the
site. Module-uo and Module-Rust side by side used to fail the second
with a slot collision. Loader, lifecycle, schema and event-contract
tests that loaded several fixture modules now load each case alone.

W1: a reconcile files a resource the game no longer has, and that is
past its `until`, as expired rather than orphaned, with the same log
line expired() writes. No MODULE_API bump: `until` and reconcile's
answer are unchanged.

W5: a dry run that passes on the draft is kept with the fingerprint of
the validated spec (three nullable columns on event_definitions).
Publish stamps the new version verified, with that dry run's reviewer
and time, when the spec it freezes has the same fingerprint, and writes
version.verified on the occurrences it re-pins. Verify answers
draftRecorded and publish answers verified; OpenAPI regenerated.

W2: the run console says "Phase p1: steps done, waiting to advance" and
"Why this phase has not advanced", not "has not started".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 00:38:39 -05:00

304 lines
12 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, runPurge } = 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 module, not the boot', async () => {
writeModule('bbb', { schema: 'CREATE TABLE IF NOT EXISTS bbb_boom (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 a bad module must not stop the site coming up.
await assert.doesNotReject(() => replayFragments({ query: rec.query, modules: loader }))
assert.equal(stateOf(loader, 'bbb').state, 'startup_failed')
assert.match(stateOf(loader, 'bbb').reason, /ER_PARSE_ERROR/)
assert.equal(rec.ran.length, 1)
})
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);' })
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, 0)
})
// ── 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)
})
// ── runPurge: the destructive twin ─────────────────────────────────────────
//
// Phase 4, slice 1. Same splitter, same pool, same serial execution as the
// replay above — pointed the other way. The two differences are deliberate and
// are what these tests are for.
test('purge runs every statement in the file, in order', async () => {
const file = path.join(tmpRoot, 'purge.sql')
fs.writeFileSync(file, [
'-- drop everything this module owns',
'DROP TABLE IF EXISTS mod_b;',
'DROP TABLE IF EXISTS mod_a;',
"DELETE FROM settings WHERE `key` = 'mod_thing';",
].join('\n'))
const rec = recorder()
const ran = await runPurge(file, { query: rec.query })
assert.equal(ran, 3)
assert.match(rec.ran[0], /DROP TABLE IF EXISTS mod_b/)
assert.match(rec.ran[2], /DELETE FROM settings/)
})
test('purge is not held to the fragment allowlist — DROP is the point of it', async () => {
// §2.6's leading-verb allowlist exists because a fragment replays on every
// boot. purge.sql never does, which is exactly why it is the one file a module
// may put a DROP in.
const file = path.join(tmpRoot, 'purge.sql')
fs.writeFileSync(file, 'DROP TABLE IF EXISTS mod_a;\nTRUNCATE TABLE mod_b;')
const rec = recorder()
assert.equal(await runPurge(file, { query: rec.query }), 2)
})
test('purge THROWS on failure, unlike the replay', async () => {
// A replay failure is one module failing to start, which the site survives by
// 503ing it. A purge failure is an operator's explicit destructive request not
// having happened — reporting success would leave them believing data is gone
// when it is not.
const file = path.join(tmpRoot, 'purge.sql')
fs.writeFileSync(file, 'DROP TABLE IF EXISTS mod_a;\nDROP TABLE mod_missing;')
const query = async (sql) => {
if (sql.includes('mod_missing')) throw new Error("Unknown table 'mod_missing'")
}
await assert.rejects(
() => runPurge(file, { query }),
// The message names WHICH statement stopped it, because a purge is not
// transactional — the ones before it have already committed and the operator
// needs to know where it got to.
/purge failed at statement 2 of 2: Unknown table 'mod_missing'/,
)
})
test('an empty purge file runs nothing and does not throw', async () => {
const file = path.join(tmpRoot, 'purge.sql')
fs.writeFileSync(file, '-- nothing to drop yet\n')
const rec = recorder()
assert.equal(await runPurge(file, { query: rec.query }), 0)
assert.deepEqual(rec.ran, [])
})