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
304 lines
12 KiB
JavaScript
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, [])
|
|
})
|