Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js and moves core's own notification streams, announce leg and users-detail routes behind it, so the three seams §1.8 and §1.9 named are exercised on every boot before any module depends on them. Registering is validate-then-commit per registrant: the loader stages what a module claims and the second pass commits it, so a module that throws halfway through register() — or fails checkDeclared after it — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule. Four decisions, all the recommended option: - announce legs became a child table. `announce_job_legs` replaces the towncrier_*/discord_* column groups, so the leg set is data: core registers `discord`, module-uo will register `towncrier`, and a module cannot ALTER a core table to add its own. Backfill is guarded on information_schema (a SELECT of a dropped column is a parse error, not a runtime one) and the columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB: three legacy jobs migrated faithfully, three replays, no duplicates. - `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the push path so a module owns fromShardEvent and calls core's publish() with a stream id it resolved; a second mapping mechanism was a leftover. The public safety filter, the kinds it reads and the streams it protects now live in one file and move together. - core registers through the same staging area a module uses, via an explicit registries.registerCore() in app.js before modules.load(). - core's six /admin/users/:id/shard/* paths now go through the `admin.users.detail` slot, and getUser moved back to admin.controller.js. Found on the way, and the reason two build tools changed: - scripts/routeManifest.js could not decode a parameterised mount. Its unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with the separator inside the group. The branch had never run. It threw rather than guessing, which is what it is for. - swagger-autogen cannot follow a route into an extension slot — the slot's router is created by declareSlot() and filled later, so there is no literal mount for a static parse. Regenerating deleted 407 lines and printed `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4). swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at the prefix the router actually hangs at in the live app — read from the express stack via routeManifest's own mountPath, so the manifest and the spec cannot disagree. swagger/mergeSpec.js is the merge helper core owes for module fragments anyway (§6.1a), proved here against core's own slot first. 884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes. The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and its `leg` no longer being a fixed enum. Co-Authored-By: Claude <noreply@anthropic.com>
577 lines
25 KiB
JavaScript
577 lines
25 KiB
JavaScript
// ── The loader's failure guarantees ────────────────────────────────────────
|
|
//
|
|
// docs/website/MODULE_API.md §4.4 promises that a module which fails ANYWHERE in
|
|
// its lifecycle fails alone: the site comes up, other modules are unaffected, and
|
|
// the failure is recorded rather than thrown. That is the property most worth a
|
|
// test, because the failure paths are the ones nobody exercises by hand — every
|
|
// manual check runs the happy path.
|
|
//
|
|
// Each test builds a throwaway modules directory, points MODULES_DIR at it and
|
|
// re-requires the loader with a clean cache, so the scan is genuinely redone.
|
|
// MODULES_DIR is read into a const at require time (it has to be: the scan is
|
|
// synchronous and happens during app.js's require), so busting the cache is the
|
|
// only honest way to point the loader somewhere else.
|
|
//
|
|
// Point the pool at a closed port BEFORE requiring anything: buildCtx pulls in
|
|
// the models, which build a mariadb pool at require time. No query is ever run.
|
|
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 registries = require('../src/modules/registries')
|
|
const { startApp } = require('./_helper')
|
|
|
|
// Requiring the real admin router declares the `admin.users.detail` extension
|
|
// slot exactly the way production does (users.router.js, at require time). Doing
|
|
// it here rather than calling declareSlot by hand matters: one test below builds
|
|
// the real tier routers, and a hand-declared slot would collide with that
|
|
// require's own declaration.
|
|
require('../src/router/v1/admin')
|
|
|
|
after(() => db.close())
|
|
|
|
let tmpRoot
|
|
|
|
/** Three empty routers standing in for core's tiers — nothing owned, nothing gated. */
|
|
const emptyTiers = () => ({
|
|
public: express.Router(),
|
|
admin: express.Router(),
|
|
player: express.Router(),
|
|
})
|
|
|
|
function freshLoader(dir, tiers = emptyTiers()) {
|
|
process.env.MODULES_DIR = dir
|
|
// The registries are process-global (there is one core), so hand the process
|
|
// back between tests. Without this a module's staged registrations from a
|
|
// previous test would still be committed, and every collision assertion below
|
|
// would be asserting against the wrong history.
|
|
registries._reset()
|
|
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
|
|
}
|
|
|
|
function writeModule(id, { manifest = {}, server, schema } = {}) {
|
|
const dir = path.join(tmpRoot, id)
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
const full = {
|
|
id,
|
|
name: id,
|
|
version: '1.0.0',
|
|
coreApi: '^1.0.0',
|
|
...(server === undefined ? {} : { server: 'index.js' }),
|
|
...(schema === undefined ? {} : { schema: 'schema.sql', purge: 'purge.sql' }),
|
|
...manifest,
|
|
}
|
|
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify(full))
|
|
if (server !== undefined) fs.writeFileSync(path.join(dir, 'index.js'), server)
|
|
if (schema !== undefined) {
|
|
fs.writeFileSync(path.join(dir, 'schema.sql'), schema)
|
|
fs.writeFileSync(path.join(dir, 'purge.sql'), '')
|
|
}
|
|
return dir
|
|
}
|
|
|
|
/** A module that registers one router answering 200 at its prefix root. */
|
|
const oneRoute = (prefix, tier = 'public') => ({
|
|
manifest: { mounts: { [tier]: [prefix] } },
|
|
server: `module.exports = (ctx, api) => {
|
|
const r = ctx.express.Router()
|
|
r.get('/', (req, res) => res.json({ ok: true }))
|
|
api.registerRoutes({ ${tier}: { '${prefix}': r } })
|
|
}`,
|
|
})
|
|
|
|
const stateOf = (loader, id) => loader.list().find((m) => m.id === id)
|
|
|
|
beforeEach(() => {
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
|
|
})
|
|
|
|
// ── Discovery and the explicit trigger ─────────────────────────────────────
|
|
|
|
test('a missing modules directory is the normal case, not an error', () => {
|
|
const loader = freshLoader(path.join(tmpRoot, 'does-not-exist'))
|
|
assert.deepEqual(loader.list(), [])
|
|
})
|
|
|
|
test('reading the module list before load() throws instead of answering []', () => {
|
|
// §7.6. The spike's scan was lazy and silent, so a caller that required the
|
|
// loader and read nothing got an empty list — indistinguishable from a core
|
|
// with no modules installed. It cost one confusing failure; it now costs an
|
|
// error naming the missing call.
|
|
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.list(), /modules\.list\(\) before modules\.load\(\)/)
|
|
})
|
|
|
|
test('load() refuses to run without all three tier routers', () => {
|
|
writeModule('aaa', { server: 'module.exports = () => {}' })
|
|
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')
|
|
// Not a module's failure — a wiring mistake in core, and the one thing in this
|
|
// file that is allowed to throw past the caller.
|
|
assert.throws(() => loader.load({ public: express.Router() }), /missing the "admin" tier router/)
|
|
})
|
|
|
|
test('load() is once-only, so a second call cannot double-mount', () => {
|
|
writeModule('aaa', oneRoute('/thing'))
|
|
const tiers = emptyTiers()
|
|
const loader = freshLoader(tmpRoot, tiers)
|
|
const before = tiers.public.stack.length
|
|
|
|
loader.load(tiers)
|
|
assert.equal(tiers.public.stack.length, before)
|
|
assert.equal(loader.list().length, 1)
|
|
})
|
|
|
|
test('modules load in alphabetical order, since nothing computes a precedence', () => {
|
|
for (const id of ['ccc', 'aaa', 'bbb']) writeModule(id, { server: 'module.exports = () => {}' })
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.deepEqual(loader.list().map((m) => m.id), ['aaa', 'bbb', 'ccc'])
|
|
})
|
|
|
|
// ── The failing module fails alone ─────────────────────────────────────────
|
|
|
|
test('a module whose entry point throws does not stop the others loading', () => {
|
|
writeModule('aaa', { server: 'module.exports = () => {}' })
|
|
writeModule('bbb', { server: 'throw new Error("boom")' })
|
|
writeModule('ccc', { server: 'module.exports = () => {}' })
|
|
const loader = freshLoader(tmpRoot)
|
|
|
|
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
|
assert.equal(stateOf(loader, 'ccc').state, 'registered')
|
|
|
|
const bad = stateOf(loader, 'bbb')
|
|
assert.equal(bad.state, 'startup_failed')
|
|
assert.match(bad.reason, /boom/)
|
|
})
|
|
|
|
test('an entry point that exports something other than a function is rejected', () => {
|
|
writeModule('notfn', { server: 'module.exports = { register: () => {} }' })
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.match(stateOf(loader, 'notfn').reason, /does not export a function/)
|
|
})
|
|
|
|
test('a coreApi mismatch is refused before the module is required at all', () => {
|
|
// The entry point would throw if it ran; the version gate must run first.
|
|
writeModule('old', {
|
|
manifest: { coreApi: '^99.0.0' },
|
|
server: 'throw new Error("should never be required")',
|
|
})
|
|
const loader = freshLoader(tmpRoot)
|
|
const mod = stateOf(loader, 'old')
|
|
assert.equal(mod.state, 'startup_failed')
|
|
assert.match(mod.reason, /needs core API \^99\.0\.0/)
|
|
})
|
|
|
|
test('an unknown manifest key is rejected, not ignored', () => {
|
|
// A typo'd key must be loud: an operator who believes they configured
|
|
// something and silently did not is worse off than one who sees a failure.
|
|
writeModule('typo', { manifest: { mount: { public: ['/x'] } } })
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.match(stateOf(loader, 'typo').reason, /unknown key "mount"/)
|
|
})
|
|
|
|
test('a module id that does not match its directory is rejected', () => {
|
|
writeModule('onedir', { manifest: { id: 'another' } })
|
|
const loader = freshLoader(tmpRoot)
|
|
// Recorded under the DIRECTORY name — the id it claimed is exactly what is
|
|
// not trusted here.
|
|
assert.match(stateOf(loader, 'onedir').reason, /does not match directory/)
|
|
})
|
|
|
|
test('an unknown extension slot is rejected; only core may declare a slot', () => {
|
|
writeModule('presumptuous', { manifest: { extensions: ['admin.users.detail', 'admin.invented'] } })
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.match(stateOf(loader, 'presumptuous').reason, /unknown extension slot "admin\.invented"/)
|
|
})
|
|
|
|
// ── Prefix ownership ───────────────────────────────────────────────────────
|
|
|
|
test('two modules cannot claim the same prefix; the first one wins', () => {
|
|
writeModule('aaa', oneRoute('/thing'))
|
|
writeModule('bbb', oneRoute('/thing'))
|
|
const loader = freshLoader(tmpRoot)
|
|
|
|
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
|
assert.match(stateOf(loader, 'bbb').reason, /already registered by module "aaa"/)
|
|
})
|
|
|
|
test('a module cannot take a prefix core owns, asked of the live tier routers', () => {
|
|
// Deliberately the REAL routers rather than a hardcoded prefix list. The spike
|
|
// hardcoded core's ~24 prefixes and they were already stale; asking express
|
|
// itself is what stops the check drifting the next time core adds a capability
|
|
// router.
|
|
/* eslint-disable global-require */
|
|
const real = {
|
|
public: require('../src/router/v1/public'),
|
|
admin: require('../src/router/v1/admin'),
|
|
player: require('../src/router/v1/player'),
|
|
}
|
|
/* eslint-enable global-require */
|
|
|
|
writeModule('greedy', { manifest: { mounts: { admin: ['/users'] } } })
|
|
writeModule('alsogreedy', { manifest: { mounts: { public: ['/wiki'] } } })
|
|
// Free in every tier core actually mounts, so it must be allowed through.
|
|
writeModule('polite', oneRoute('/widgets', 'player'))
|
|
|
|
const loader = freshLoader(tmpRoot, real)
|
|
assert.match(stateOf(loader, 'greedy').reason, /owned by core/)
|
|
assert.match(stateOf(loader, 'alsogreedy').reason, /owned by core/)
|
|
assert.equal(stateOf(loader, 'polite').state, 'registered')
|
|
})
|
|
|
|
test('a root-mounted core layer does not make every prefix look taken', () => {
|
|
// public/index.js ends with `use('/', siteRouter)` and admin with the dashboard
|
|
// router; both match every path. Counting them would report every prefix as
|
|
// owned and no module could ever mount.
|
|
const tiers = emptyTiers()
|
|
tiers.public.use('/posts', express.Router())
|
|
tiers.public.use('/', express.Router())
|
|
|
|
writeModule('fine', oneRoute('/widgets'))
|
|
writeModule('taken', oneRoute('/posts'))
|
|
const loader = freshLoader(tmpRoot, tiers)
|
|
|
|
assert.equal(stateOf(loader, 'fine').state, 'registered')
|
|
assert.match(stateOf(loader, 'taken').reason, /owned by core/)
|
|
})
|
|
|
|
test('a prefix with a slash or a parameter in it is rejected', () => {
|
|
// A prefix that could contain either would let a module reach outside the slot
|
|
// it was given.
|
|
writeModule('nested', { manifest: { mounts: { public: ['/a/b'] } } })
|
|
writeModule('parameterised', { manifest: { mounts: { public: ['/:id'] } } })
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.match(stateOf(loader, 'nested').reason, /bad prefix "\/a\/b"/)
|
|
assert.match(stateOf(loader, 'parameterised').reason, /bad prefix "\/:id"/)
|
|
})
|
|
|
|
test('registering a prefix that was never declared is rejected', () => {
|
|
// module.json is what the admin panel, the collision check and the reviewer
|
|
// all read, so it has to be the truth rather than a hint.
|
|
writeModule('sneaky', {
|
|
manifest: { mounts: { public: ['/declared'] } },
|
|
server: `module.exports = (ctx, api) => api.registerRoutes({
|
|
public: { '/declared': ctx.express.Router(), '/undeclared': ctx.express.Router() },
|
|
})`,
|
|
})
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.match(stateOf(loader, 'sneaky').reason, /registered public\/undeclared without declaring it/)
|
|
})
|
|
|
|
test('declaring a prefix and never registering it is rejected too', () => {
|
|
writeModule('forgetful', {
|
|
manifest: { mounts: { public: ['/a', '/b'] } },
|
|
server: "module.exports = (ctx, api) => api.registerRoutes({ public: { '/a': ctx.express.Router() } })",
|
|
})
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.match(stateOf(loader, 'forgetful').reason, /declared public\/b but never registered it/)
|
|
})
|
|
|
|
test('registering the same thing twice is an error, not a silent overwrite', () => {
|
|
writeModule('twice', {
|
|
manifest: { mounts: { public: ['/x'] } },
|
|
server: `module.exports = (ctx, api) => {
|
|
api.registerRoutes({ public: { '/x': ctx.express.Router() } })
|
|
api.registerRoutes({ public: { '/x': ctx.express.Router() } })
|
|
}`,
|
|
})
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.match(stateOf(loader, 'twice').reason, /registerRoutes\(\) called twice/)
|
|
})
|
|
|
|
// ── 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);' })
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.match(stateOf(loader, 'thief').reason, /declares core table "users"/)
|
|
})
|
|
|
|
test('a schema fragment table must carry the module id as a prefix', () => {
|
|
writeModule('mine', { schema: 'CREATE TABLE IF NOT EXISTS widgets (id INT);' })
|
|
assert.match(stateOf(freshLoader(tmpRoot), 'mine').reason, /not prefixed "mine_"/)
|
|
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
|
|
writeModule('mine', { schema: 'CREATE TABLE IF NOT EXISTS mine_widgets (id INT);' })
|
|
assert.equal(stateOf(freshLoader(tmpRoot), 'mine').state, 'registered')
|
|
})
|
|
|
|
test('two modules cannot own the same table either', () => {
|
|
writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);' })
|
|
writeModule('bbb', {
|
|
manifest: { id: 'bbb' },
|
|
schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);',
|
|
})
|
|
const loader = freshLoader(tmpRoot)
|
|
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
|
assert.match(stateOf(loader, 'bbb').reason, /already owned by module "aaa"/)
|
|
})
|
|
|
|
test('declaring a schema without a purge is rejected', () => {
|
|
const dir = path.join(tmpRoot, 'noway')
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
fs.writeFileSync(
|
|
path.join(dir, 'module.json'),
|
|
JSON.stringify({ id: 'noway', name: 'x', version: '1.0.0', coreApi: '^1.0.0', schema: 'schema.sql' }),
|
|
)
|
|
const loader = freshLoader(tmpRoot)
|
|
// A module that can create tables and cannot drop them leaves an operator with
|
|
// orphaned data and no supported way to remove it.
|
|
assert.match(stateOf(loader, 'noway').reason, /declares schema but no purge/)
|
|
})
|
|
|
|
test('a declared purge file that is not there is rejected', () => {
|
|
writeModule('gone', { schema: 'CREATE TABLE IF NOT EXISTS gone_x (id INT);' })
|
|
fs.unlinkSync(path.join(tmpRoot, 'gone', 'purge.sql'))
|
|
const loader = freshLoader(tmpRoot)
|
|
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 () => {
|
|
writeModule('good', oneRoute('/widgets'))
|
|
writeModule('bad', { ...oneRoute('/broken'), server: 'throw new Error("boom")' })
|
|
|
|
const tiers = emptyTiers()
|
|
freshLoader(tmpRoot, tiers)
|
|
const app = await startApp((a) => a.use('/public', tiers.public))
|
|
|
|
try {
|
|
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
|
|
// Failed BEFORE mounting, so its routes are not absent-with-a-503 — they do
|
|
// not exist at all (§4.4, left-hand column).
|
|
assert.equal((await fetch(`${app.url}/public/broken`)).status, 404)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
test('a module that fails AFTER mounting keeps its URLs and answers 503', async () => {
|
|
// The right-hand column of §4.4, and the reason routes.manifest.json can be
|
|
// generated off a dead database: the URL surface must not depend on whether a
|
|
// boot step succeeded on the generating machine. PR 3 (schema replay) and PR 5
|
|
// (onBoot) are the two things that will trip this in real life; here the state
|
|
// is moved by hand, because the loader is the thing under test.
|
|
writeModule('later', oneRoute('/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)
|
|
|
|
assert.equal(stateOf(loader, 'later').state, 'registered')
|
|
|
|
loader.setState('later', 'startup_failed', 'schema fragment blew up')
|
|
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503)
|
|
assert.equal(stateOf(loader, 'later').reason, 'schema fragment blew up')
|
|
|
|
// The 404 leg becomes reachable for real in PR 5, when the boot reconcile
|
|
// reads a `disabled` row out of installed_modules. A disabled module is
|
|
// mounted and guarded, never unmounted (§4.5) — same reason as the 503.
|
|
loader.setState('later', 'disabled')
|
|
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 404)
|
|
|
|
loader.setState('later', 'started')
|
|
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
test('setState refuses a state that is not one, and shrugs at an unknown id', () => {
|
|
writeModule('here', oneRoute('/widgets'))
|
|
const loader = freshLoader(tmpRoot)
|
|
|
|
assert.throws(() => loader.setState('here', 'enabled'), /unknown module state "enabled"/)
|
|
// An id with no record is not the boot path's problem to escalate.
|
|
assert.doesNotThrow(() => loader.setState('never-installed', 'started'))
|
|
})
|
|
|
|
// ── ctx ────────────────────────────────────────────────────────────────────
|
|
|
|
test('ctx exposes exactly the documented surface, and is frozen', () => {
|
|
const seen = path.join(tmpRoot, 'probe-out.json')
|
|
writeModule('probe', {
|
|
server: `const fs = require('fs')
|
|
module.exports = (ctx) => {
|
|
let mutable = true
|
|
try { ctx.db.query = null; mutable = ctx.db.query === null } catch { mutable = false }
|
|
fs.writeFileSync(${JSON.stringify(seen)}, JSON.stringify({
|
|
keys: Object.keys(ctx).sort(),
|
|
middleware: Object.keys(ctx.middleware).sort(),
|
|
moduleId: ctx.moduleId,
|
|
mutable,
|
|
}))
|
|
}`,
|
|
})
|
|
assert.equal(stateOf(freshLoader(tmpRoot), 'probe').state, 'registered')
|
|
|
|
const probe = JSON.parse(fs.readFileSync(seen, 'utf8'))
|
|
assert.deepEqual(probe.keys, [
|
|
'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths',
|
|
'posts', 'push', 'secretBox', 'settings', 'uploads', 'validator',
|
|
])
|
|
assert.deepEqual(probe.middleware, ['noindex', 'requireAuth', 'requireRole', 'siteMode', 'validate'])
|
|
assert.equal(probe.moduleId, 'probe')
|
|
assert.equal(probe.mutable, false, 'ctx members must be frozen')
|
|
})
|
|
|
|
test('the register calls PR 5 owns throw rather than silently accepting', () => {
|
|
// An accepting no-op would let a module believe it had registered a boot hook
|
|
// and fail silently at the far end.
|
|
for (const [call, pr] of [
|
|
['onBoot', 5],
|
|
['onShutdown', 5],
|
|
]) {
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
|
|
writeModule('early', { server: `module.exports = (ctx, api) => api.${call}(() => {})` })
|
|
assert.match(
|
|
stateOf(freshLoader(tmpRoot), 'early').reason,
|
|
new RegExp(`${call}: not available until phase 2 PR ${pr}`),
|
|
)
|
|
}
|
|
})
|
|
|
|
// ── Staged registrations are committed only for a module that survives ─────
|
|
|
|
test('a module that fails AFTER registering leaves nothing in the registries', () => {
|
|
// The registry-side twin of the second-pass mount rule. register() runs before
|
|
// checkDeclared, so a module can stage a stream catalog and then be rejected —
|
|
// and a half-registered catalog is worse than a missing one, because it is a
|
|
// subscribable stream nothing will ever publish to.
|
|
writeModule('halfway', {
|
|
manifest: { mounts: { public: ['/declared'] } },
|
|
server: `module.exports = (ctx, api) => {
|
|
api.registerNotificationStreams([{ id: 'halfway.thing', label: 'Thing' }])
|
|
api.registerAnnounceLeg({ leg: 'halfway.leg', label: 'L', dispatch: async () => ({}), classify: () => ({}) })
|
|
// declared /declared and never registered it → rejected by checkDeclared
|
|
}`,
|
|
})
|
|
const loader = freshLoader(tmpRoot)
|
|
|
|
assert.match(stateOf(loader, 'halfway').reason, /declared public\/declared but never registered it/)
|
|
assert.equal(registries.isValidStream('halfway.thing'), false)
|
|
assert.equal(registries.announceLeg('halfway.leg'), null)
|
|
})
|
|
|
|
test('a module colliding with an already-registered name fails alone, unmounted', () => {
|
|
const tiers = emptyTiers()
|
|
writeModule('first', {
|
|
manifest: { mounts: { public: ['/first'] } },
|
|
server: `module.exports = (ctx, api) => {
|
|
api.registerRoutes({ public: { '/first': ctx.express.Router() } })
|
|
api.registerNotificationStreams([{ id: 'first.shared', label: 'Shared' }])
|
|
}`,
|
|
})
|
|
writeModule('second', {
|
|
manifest: { mounts: { public: ['/second'] } },
|
|
server: `module.exports = (ctx, api) => {
|
|
api.registerRoutes({ public: { '/second': ctx.express.Router() } })
|
|
api.registerNotificationStreams([{ id: 'second.ok', label: 'Ok' }, { id: 'first.shared', label: 'Mine' }])
|
|
}`,
|
|
})
|
|
const loader = freshLoader(tmpRoot, tiers)
|
|
|
|
assert.equal(stateOf(loader, 'first').state, 'registered')
|
|
assert.match(stateOf(loader, 'second').reason, /already registered by "first"/)
|
|
// Not even the claim that did not collide.
|
|
assert.equal(registries.isValidStream('second.ok'), false)
|
|
// And the loser is not mounted at all. Asked of the live router the way the
|
|
// prefix-ownership check asks it, rather than by counting layers — one mount
|
|
// produces two (the dispatch guard, then the module's router).
|
|
const claims = (prefix) =>
|
|
tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
|
|
assert.equal(claims('/first'), true)
|
|
assert.equal(claims('/second'), false)
|
|
})
|