feat(modules): replay module schema fragments after core's (phase 2, PR 3) #130
@@ -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 }
|
||||
|
||||
84
server/src/modules/schema.js
Normal file
84
server/src/modules/schema.js
Normal file
@@ -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<any>} [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 }
|
||||
@@ -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')
|
||||
|
||||
37
server/src/utils/sqlStatements.js
Normal file
37
server/src/utils/sqlStatements.js
Normal file
@@ -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 }
|
||||
@@ -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 () => {
|
||||
|
||||
247
server/test/moduleSchema.test.js
Normal file
247
server/test/moduleSchema.test.js
Normal file
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user