feat(modules): the three de-entanglement registries, with core as the registrant
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>
This commit is contained in:
216
server/test/moduleRegistries.test.js
Normal file
216
server/test/moduleRegistries.test.js
Normal file
@@ -0,0 +1,216 @@
|
||||
// ── The three de-entanglement registries ───────────────────────────────────
|
||||
//
|
||||
// Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. The properties worth a test
|
||||
// are the ones nobody exercises by hand: what happens when two registrants want
|
||||
// the same name, and what is left behind when one of them fails halfway.
|
||||
//
|
||||
// Point the DB at a closed port BEFORE requiring anything — registerCore() pulls
|
||||
// in the announce legs, which pull in models that build a pool at require time.
|
||||
// No query is ever run.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
// Declares `admin.users.detail` the way production does — at require time, in the
|
||||
// router that owns the resource.
|
||||
require('../src/router/v1/admin')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
beforeEach(() => registries._reset())
|
||||
|
||||
const stream = (id, over = {}) => ({ id, label: id, ...over })
|
||||
const leg = (id, over = {}) => ({ leg: id, label: id, dispatch: async () => ({ ok: true }), classify: () => ({ outcome: 'done' }), ...over })
|
||||
|
||||
/** Register a batch as `owner` and return the error message, or null on success. */
|
||||
function tryApply(owner, build) {
|
||||
const api = registries.stage(owner)
|
||||
try {
|
||||
build(api)
|
||||
registries.apply(api.staged)
|
||||
return null
|
||||
} catch (err) {
|
||||
return err.message
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core goes through the same door ────────────────────────────────────────
|
||||
|
||||
test('registerCore registers core AND the not-yet-extracted shard content', () => {
|
||||
registries.registerCore()
|
||||
|
||||
const ids = registries.allStreams().map((s) => s.id)
|
||||
// Core's one stream first, then the seven that leave with module-uo.
|
||||
assert.equal(ids[0], 'news.post')
|
||||
assert.equal(ids.length, 8)
|
||||
assert.ok(ids.includes('vendor.sale'))
|
||||
|
||||
assert.deepEqual(registries.announceLegIds(), ['discord', 'towncrier'])
|
||||
assert.equal(registries.slotFilledBy('admin.users.detail'), 'core')
|
||||
assert.equal(registries.isCoreRegistered(), true)
|
||||
})
|
||||
|
||||
test('registerCore is idempotent — a second call registers nothing twice', () => {
|
||||
registries.registerCore()
|
||||
const before = registries.allStreams().length
|
||||
registries.registerCore()
|
||||
assert.equal(registries.allStreams().length, before)
|
||||
})
|
||||
|
||||
test('the wire shape of a stream survives registration', () => {
|
||||
registries.registerCore()
|
||||
const personal = registries.allStreams().find((s) => s.id === 'vendor.sale')
|
||||
// Two booleans, not the contract's single `scope`: this object is the body of
|
||||
// GET /auth/me/notifications/streams and a shipped Android client reads both.
|
||||
assert.equal(personal.personal, true)
|
||||
assert.equal(personal.requiresLinkedAccount, true)
|
||||
assert.ok(personal.description.length > 0)
|
||||
assert.ok(registries.personalStreams().has('vendor.sale'))
|
||||
assert.equal(registries.isValidStream('vendor.sale'), true)
|
||||
assert.equal(registries.isValidStream('nope.nope'), false)
|
||||
})
|
||||
|
||||
// ── Namespacing ────────────────────────────────────────────────────────────
|
||||
|
||||
test('a module’s stream ids must carry its module id', () => {
|
||||
assert.equal(tryApply('rust', (api) => api.registerNotificationStreams([stream('rust.raid')])), null)
|
||||
assert.match(
|
||||
tryApply('rust', (api) => api.registerNotificationStreams([stream('raid.started')])),
|
||||
/not namespaced "rust\."/,
|
||||
)
|
||||
})
|
||||
|
||||
test('the seven pre-module-system stream ids are grandfathered to uo alone', () => {
|
||||
// Renaming them would be a data migration (notification_subs rows) and a break
|
||||
// for a shipped Android client — the same reasoning as the loader's legacy
|
||||
// table prefixes.
|
||||
assert.equal(tryApply('uo', (api) => api.registerNotificationStreams([stream('vendor.sale')])), null)
|
||||
registries._reset()
|
||||
assert.match(
|
||||
tryApply('rust', (api) => api.registerNotificationStreams([stream('vendor.sale')])),
|
||||
/not namespaced "rust\."/,
|
||||
)
|
||||
})
|
||||
|
||||
test('an announce leg must be namespaced too, with towncrier grandfathered to uo', () => {
|
||||
assert.equal(tryApply('uo', (api) => api.registerAnnounceLeg(leg('towncrier'))), null)
|
||||
registries._reset()
|
||||
assert.equal(tryApply('rust', (api) => api.registerAnnounceLeg(leg('rust.motd'))), null)
|
||||
registries._reset()
|
||||
assert.match(
|
||||
tryApply('rust', (api) => api.registerAnnounceLeg(leg('towncrier'))),
|
||||
/not namespaced "rust\."/,
|
||||
)
|
||||
})
|
||||
|
||||
// ── Collisions name the holder ─────────────────────────────────────────────
|
||||
|
||||
test('a stream core already registered is refused, naming core', () => {
|
||||
registries.registerCore()
|
||||
assert.match(
|
||||
tryApply('uo', (api) => api.registerNotificationStreams([stream('news.post')])),
|
||||
/already registered by "core"/,
|
||||
)
|
||||
})
|
||||
|
||||
test('two modules cannot register the same stream or leg', () => {
|
||||
assert.equal(tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])), null)
|
||||
// A second module can only reach it via its own namespace, so collide on a
|
||||
// grandfathered id, which is the realistic case.
|
||||
assert.match(
|
||||
tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])),
|
||||
/already registered by "aaa"/,
|
||||
)
|
||||
assert.equal(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), null)
|
||||
assert.match(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), /already registered by "bbb"/)
|
||||
})
|
||||
|
||||
test('a batch cannot claim the same name twice', () => {
|
||||
assert.match(
|
||||
tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.x'), stream('aaa.x')])),
|
||||
/registered twice/,
|
||||
)
|
||||
})
|
||||
|
||||
// ── Validate-then-commit ───────────────────────────────────────────────────
|
||||
|
||||
test('a batch whose LAST claim collides commits none of the earlier ones', () => {
|
||||
// The property the whole staging design exists for. A half-registered catalog
|
||||
// is worse than a missing one: a subscribable stream nothing will publish to.
|
||||
registries.registerCore()
|
||||
const before = registries.allStreams().length
|
||||
|
||||
const err = tryApply('uo', (api) => {
|
||||
api.registerNotificationStreams([stream('uo.first'), stream('uo.second')])
|
||||
api.registerAnnounceLeg(leg('uo.leg'))
|
||||
api.registerNotificationStreams([stream('news.post')]) // collides with core
|
||||
})
|
||||
|
||||
assert.match(err, /already registered by "core"/)
|
||||
assert.equal(registries.allStreams().length, before, 'uo.first / uo.second must not be registered')
|
||||
assert.equal(registries.isValidStream('uo.first'), false)
|
||||
assert.equal(registries.announceLeg('uo.leg'), null)
|
||||
})
|
||||
|
||||
test('staging alone changes nothing — only apply() commits', () => {
|
||||
const api = registries.stage('uo')
|
||||
api.registerNotificationStreams([stream('uo.staged')])
|
||||
assert.equal(registries.isValidStream('uo.staged'), false)
|
||||
registries.apply(api.staged)
|
||||
assert.equal(registries.isValidStream('uo.staged'), true)
|
||||
})
|
||||
|
||||
// ── Shape checks fire at the call ──────────────────────────────────────────
|
||||
|
||||
test('a malformed claim throws where the registrant made it, not at apply()', () => {
|
||||
const api = registries.stage('uo')
|
||||
assert.throws(() => api.registerNotificationStreams([stream('nodots')]), /bad stream id/)
|
||||
assert.throws(() => api.registerNotificationStreams([{ id: 'uo.x' }]), /has no label/)
|
||||
assert.throws(() => api.registerNotificationStreams('not an array'), /expected an array/)
|
||||
assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { dispatch: null })), /has no dispatch/)
|
||||
assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { classify: null })), /has no classify/)
|
||||
assert.throws(() => api.registerAnnounceLeg({ leg: 'NOPE' }), /bad leg id/)
|
||||
})
|
||||
|
||||
// ── Extension slots ────────────────────────────────────────────────────────
|
||||
|
||||
test('only a declared slot can be filled, and only once', () => {
|
||||
const router = () => {}
|
||||
const api = registries.stage('uo')
|
||||
assert.throws(() => api.registerExtension('admin.invented', router), /unknown extension slot/)
|
||||
assert.throws(() => api.registerExtension('admin.users.detail', 'not a router'), /is not a router/)
|
||||
|
||||
registries.registerCore() // core fills it
|
||||
assert.match(
|
||||
tryApply('uo', (a) => a.registerExtension('admin.users.detail', router)),
|
||||
/already filled by "core"/,
|
||||
)
|
||||
})
|
||||
|
||||
test('a slot cannot be declared twice', () => {
|
||||
assert.throws(() => registries.declareSlot('admin.users.detail'), /already declared/)
|
||||
})
|
||||
|
||||
// ── The slot's spec, which static analysis cannot see ──────────────────────
|
||||
|
||||
test('the filled slot’s router is findable in the live app, at the resource path', () => {
|
||||
// Guards swagger/slotSpecs.js: it recovers each slot's mount prefix from the
|
||||
// live stack rather than a hardcoded table. If this stops working, the six
|
||||
// slot routes vanish from swagger-output.json with `Success` printed — the
|
||||
// exact silent failure the spike hit (MODULE_API.md §7.4).
|
||||
/* eslint-disable global-require */
|
||||
const app = require('../src/app')
|
||||
const { findMountPrefix } = require('../swagger/slotSpecs')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const [slot] = registries.filledSlots()
|
||||
assert.ok(slot, 'app.js must have registered core, filling the slot')
|
||||
assert.equal(slot.slot, 'admin.users.detail')
|
||||
assert.ok(slot.specFile, 'core names the file its slot router is generated from')
|
||||
assert.equal(findMountPrefix(app._router.stack, slot.router), '/api/v1/admin/users/:id')
|
||||
})
|
||||
Reference in New Issue
Block a user