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>
357 lines
15 KiB
JavaScript
357 lines
15 KiB
JavaScript
// ── The de-entanglement registries ─────────────────────────────────────────
|
|
//
|
|
// Phase 2, PR 4 of docs/website/MODULE_SYSTEM.md §2.7 — the three seams §1.8 and
|
|
// §1.9 identified, where core code and game-specific content are tangled in one
|
|
// file and a folder move cannot separate them. The normative contract is
|
|
// docs/website/MODULE_API.md §2.4.
|
|
//
|
|
// The three:
|
|
//
|
|
// 1. `registerExtension(slot, router)` — §1.9. Module routes hanging off a
|
|
// CORE resource (`/admin/users/:id`), so all six shard sub-paths keep their
|
|
// URLs while core never learns what "shard" means.
|
|
// 2. `registerNotificationStreams(streams)` — §1.8. The push-stream catalog:
|
|
// push INFRASTRUCTURE is core, this CATALOG is content.
|
|
// 3. `registerAnnounceLeg({ leg, label, dispatch, classify })` — §1.8. The news
|
|
// dispatcher's delivery legs; Discord is core, town crier is content.
|
|
//
|
|
// **Core registers through these functions too, and is the only registrant until
|
|
// Phase 3.** `registerCore()` below is called explicitly from app.js before
|
|
// `modules.load()` — explicit, never lazy, the same decision the loader's trigger
|
|
// took (MODULE_API.md §7.6). Core going through the same door is the point: a
|
|
// registry only core's hardcoded base bypasses is a registry whose first real
|
|
// exercise is a module, which is the drift this PR exists to prevent.
|
|
//
|
|
// **Registering is validate-then-commit, per registrant.** `apply()` checks every
|
|
// claim in a batch before it writes any of them, so a module that registers two
|
|
// streams and then throws — or fails a later validation step in the loader — has
|
|
// left nothing behind. That is the registry-side twin of the loader's second-pass
|
|
// mount rule: nothing a module claims takes effect until the module as a whole is
|
|
// known good.
|
|
//
|
|
// Nothing here reaches the database or the network. It is a require-time-safe
|
|
// collection of what core and modules have declared, read at request time.
|
|
|
|
const express = require('express')
|
|
|
|
const log = require('../utils/logger')('modules')
|
|
|
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
|
|
// slot → { router, filledBy }. `router` is created when CORE DECLARES the slot
|
|
// and mounted immediately; registrants `use()` into it later. That indirection is
|
|
// not optional: users.router.js is required while app.js is being built, long
|
|
// before any module has been scanned, so the thing core mounts has to be a stable
|
|
// object that can still be empty.
|
|
const slots = new Map()
|
|
|
|
// Registration order, which is display order in the app's notifications screen.
|
|
const streams = []
|
|
const streamOwners = new Map() // stream id → owner id, for the collision message
|
|
|
|
// leg id → { owner, leg, label, dispatch, classify }
|
|
const legs = new Map()
|
|
|
|
let coreRegistered = false
|
|
|
|
// Stream ids that predate the module system and may not carry their owner's
|
|
// prefix — the exact counterpart of the loader's LEGACY_TABLE_PREFIXES, for the
|
|
// exact same reason. These seven ids are stored in `notification_subs` rows and
|
|
// are read by a shipped Android client; renaming them in Phase 3 would be a data
|
|
// migration and a client break, so `uo` keeps them and the prefix rule stays real
|
|
// for every module written after it.
|
|
const LEGACY_STREAM_IDS = {
|
|
uo: [
|
|
'server.status', 'idoc.warning', 'champ.start', 'governor.election',
|
|
'vendor.sale', 'house.idoc', 'account.login',
|
|
],
|
|
}
|
|
|
|
// Likewise for announce legs: `towncrier` is a stored value in
|
|
// announce_job_legs.leg and the body of the admin retry endpoint.
|
|
const LEGACY_LEGS = { uo: ['towncrier'] }
|
|
|
|
const STREAM_ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/
|
|
const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/
|
|
|
|
// A module's claim must carry its id. Core's ids are its own namespace, and the
|
|
// grandfathered names are the ones that predate all of this.
|
|
function namespaced(owner, name, legacy) {
|
|
return owner === 'core' || name.startsWith(`${owner}.`) || (legacy[owner] || []).includes(name)
|
|
}
|
|
|
|
// ── Extension slots (§1.9) ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Core declares an extension slot and gets the router to mount for it.
|
|
*
|
|
* ONLY core may declare a slot; a module may only fill one (MODULE_API.md §2.4).
|
|
* That asymmetry is why this is not on the `api` object handed to a module.
|
|
*
|
|
* `mergeParams` so the slot's router sees the parent's `:id`. Core's own routes
|
|
* on the resource are declared before the slot is mounted, so first-match-wins
|
|
* gives core the path conflict, as the contract requires.
|
|
*
|
|
* @returns {import('express').Router} mount this at the resource, once.
|
|
*/
|
|
function declareSlot(slot) {
|
|
if (slots.has(slot)) throw new Error(`extension slot "${slot}" already declared`)
|
|
const router = express.Router({ mergeParams: true })
|
|
slots.set(slot, { router, filledBy: null })
|
|
return router
|
|
}
|
|
|
|
/** Does this slot exist? The loader asks, to validate `extensions` in a manifest. */
|
|
const hasSlot = (slot) => slots.has(slot)
|
|
|
|
/** Who filled a slot, or null. */
|
|
const slotFilledBy = (slot) => (slots.get(slot) || {}).filledBy || null
|
|
|
|
/**
|
|
* Every FILLED slot, for the OpenAPI build step (swagger/slotSpecs.js).
|
|
*
|
|
* `router` is the slot's own stable router — the object mounted on the resource —
|
|
* so the build can find it in the live express stack and recover the prefix it
|
|
* hangs at without a hardcoded table.
|
|
*/
|
|
const filledSlots = () =>
|
|
[...slots.entries()]
|
|
.filter(([, e]) => e.filledBy)
|
|
.map(([slot, e]) => ({ slot, filledBy: e.filledBy, router: e.router, specFile: e.specFile || null }))
|
|
|
|
// ── Notification streams (§1.8) ────────────────────────────────────────────
|
|
|
|
/** The whole catalog, core's entries first, in registration order. */
|
|
const allStreams = () => streams.slice()
|
|
|
|
/** Is this a stream anyone registered? Gates a subscription write. */
|
|
const isValidStream = (id) => streamOwners.has(id)
|
|
|
|
/** Ids of the owner-keyed streams — those needing a linked game account. */
|
|
const personalStreams = () => new Set(streams.filter((s) => s.personal).map((s) => s.id))
|
|
|
|
// ── Announce legs (§1.8) ───────────────────────────────────────────────────
|
|
|
|
/** Every registered leg, in registration order. */
|
|
const announceLegs = () => [...legs.values()]
|
|
|
|
/** Just the ids — the enqueue order and the retry endpoint's allowlist. */
|
|
const announceLegIds = () => [...legs.keys()]
|
|
|
|
/** One leg, or null. */
|
|
const announceLeg = (leg) => legs.get(leg) || null
|
|
|
|
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
|
//
|
|
// Split from the collision checks below on the same line PR 3 drew through
|
|
// schema-fragment validation: what can be decided from the argument alone is
|
|
// decided AT THE CALL, so the error carries the registrant's own stack. What
|
|
// depends on other registrants has to wait for the batch to be complete.
|
|
|
|
function checkStreamShape(entry) {
|
|
if (!entry || !STREAM_ID.test(entry.id || '')) {
|
|
throw new Error(`registerNotificationStreams: bad stream id "${entry && entry.id}"`)
|
|
}
|
|
if (!entry.label) throw new Error(`registerNotificationStreams: stream "${entry.id}" has no label`)
|
|
return {
|
|
id: entry.id,
|
|
label: entry.label,
|
|
description: entry.description || '',
|
|
personal: Boolean(entry.personal),
|
|
requiresLinkedAccount: Boolean(entry.requiresLinkedAccount),
|
|
}
|
|
}
|
|
|
|
function checkLegShape(entry) {
|
|
const { leg, label, dispatch, classify } = entry || {}
|
|
if (!LEG_ID.test(leg || '')) throw new Error(`registerAnnounceLeg: bad leg id "${leg}"`)
|
|
if (typeof dispatch !== 'function') throw new Error(`announce leg "${leg}" has no dispatch()`)
|
|
if (typeof classify !== 'function') throw new Error(`announce leg "${leg}" has no classify()`)
|
|
return { leg, label: label || leg, dispatch, classify }
|
|
}
|
|
|
|
// `specFile` is CORE-ONLY and is not on the module-facing signature. A slot's
|
|
// router reaches the app through declareSlot(), which no static parse of app.js
|
|
// can follow, so swagger-autogen would silently drop every route in it — the
|
|
// spike's exact failure (MODULE_API.md §7.4). Core names the file so
|
|
// `npm run swagger` can generate a fragment from it and merge it into the
|
|
// committed spec. A MODULE has no equivalent need: it ships a prebuilt
|
|
// `swagger-fragment.json` in its bundle (§6.1a), because core never has its
|
|
// sources to analyse.
|
|
function checkExtensionShape(slot, router, specFile) {
|
|
if (!slots.has(slot)) throw new Error(`unknown extension slot "${slot}"`)
|
|
if (typeof router !== 'function') throw new Error(`registerExtension: ${slot} is not a router`)
|
|
return { slot, router, specFile: specFile || null }
|
|
}
|
|
|
|
// ── Staging + commit ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* A registrant's staging area: shape-checked claims, not yet visible to anyone.
|
|
*
|
|
* The loader hands one of these to a module through `api`, and `registerCore()`
|
|
* builds one for core. Nothing a registrant says is readable through
|
|
* `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
|
|
*/
|
|
function stage(owner) {
|
|
const staged = { owner, streams: [], legs: [], extensions: [] }
|
|
return {
|
|
staged,
|
|
registerNotificationStreams(entries) {
|
|
if (!Array.isArray(entries)) throw new Error('registerNotificationStreams: expected an array')
|
|
for (const e of entries) staged.streams.push(checkStreamShape(e))
|
|
},
|
|
registerAnnounceLeg(entry) {
|
|
staged.legs.push(checkLegShape(entry))
|
|
},
|
|
registerExtension(slot, router, specFile) {
|
|
staged.extensions.push(checkExtensionShape(slot, router, specFile))
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a staged batch against everything already registered, then commit it.
|
|
*
|
|
* Validation is TOTAL before the first write, so this either takes all of a
|
|
* registrant's claims or none of them. Throws on the first collision, naming who
|
|
* holds the thing already — which is the message an operator needs and the one
|
|
* PR 2 learned to protect (mounting inside the scan loop made every collision
|
|
* look like it was with core).
|
|
*/
|
|
function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions }) {
|
|
// ── validate ──
|
|
const seenStreams = new Set()
|
|
for (const s of newStreams) {
|
|
const held = streamOwners.get(s.id)
|
|
if (held) throw new Error(`stream "${s.id}" is already registered by "${held}"`)
|
|
if (seenStreams.has(s.id)) throw new Error(`stream "${s.id}" registered twice`)
|
|
if (!namespaced(owner, s.id, LEGACY_STREAM_IDS)) {
|
|
throw new Error(`stream "${s.id}" is not namespaced "${owner}."`)
|
|
}
|
|
seenStreams.add(s.id)
|
|
}
|
|
|
|
const seenLegs = new Set()
|
|
for (const l of newLegs) {
|
|
const held = legs.get(l.leg)
|
|
if (held) throw new Error(`announce leg "${l.leg}" is already registered by "${held.owner}"`)
|
|
if (seenLegs.has(l.leg)) throw new Error(`announce leg "${l.leg}" registered twice`)
|
|
if (!namespaced(owner, l.leg, LEGACY_LEGS)) {
|
|
throw new Error(`announce leg "${l.leg}" is not namespaced "${owner}."`)
|
|
}
|
|
seenLegs.add(l.leg)
|
|
}
|
|
|
|
const seenSlots = new Set()
|
|
for (const x of newExtensions) {
|
|
const entry = slots.get(x.slot)
|
|
if (entry.filledBy) {
|
|
throw new Error(`extension slot "${x.slot}" is already filled by "${entry.filledBy}"`)
|
|
}
|
|
if (seenSlots.has(x.slot)) throw new Error(`extension slot "${x.slot}" filled twice`)
|
|
seenSlots.add(x.slot)
|
|
}
|
|
|
|
// ── commit — nothing below can fail ──
|
|
for (const s of newStreams) {
|
|
streamOwners.set(s.id, owner)
|
|
streams.push(s)
|
|
}
|
|
for (const l of newLegs) legs.set(l.leg, { owner, ...l })
|
|
for (const x of newExtensions) {
|
|
const entry = slots.get(x.slot)
|
|
entry.filledBy = owner
|
|
entry.specFile = x.specFile
|
|
entry.router.use(x.router)
|
|
}
|
|
}
|
|
|
|
// ── Core's own registrations ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Register everything CORE owns, through the same staging area a module uses.
|
|
*
|
|
* Called once from app.js, before `modules.load()` — before, because a module's
|
|
* collision checks are asked against what is already registered, and core's
|
|
* claims must be the ones already there.
|
|
*
|
|
* What is here is what survives Phase 3. Everything after the boundary comment is
|
|
* shard content and leaves with module-uo, registered rather than hardcoded so
|
|
* the seam is exercised on every boot long before a module first uses it.
|
|
*/
|
|
function registerCore() {
|
|
if (coreRegistered) return
|
|
|
|
/* eslint-disable global-require */
|
|
const coreStreams = require('../config/coreStreams')
|
|
const discordLeg = require('../utils/discordAnnounce')
|
|
const shardStreams = require('../config/shardStreams')
|
|
const townCrierLeg = require('../utils/shardAnnounce')
|
|
const shardExtension = require('../router/v1/admin/usersShard.router')
|
|
/* eslint-enable global-require */
|
|
|
|
const api = stage('core')
|
|
api.registerNotificationStreams(coreStreams.STREAMS)
|
|
api.registerAnnounceLeg(discordLeg.leg)
|
|
|
|
// ── Phase 3 boundary ────────────────────────────────────────────────────
|
|
// These three lines become module-uo's register() body, with 'core' becoming
|
|
// 'uo'. Nothing else in core has to change for that to happen — which is the
|
|
// whole claim PR 4 is making.
|
|
api.registerNotificationStreams(shardStreams.STREAMS)
|
|
api.registerAnnounceLeg(townCrierLeg.leg)
|
|
api.registerExtension('admin.users.detail', shardExtension, require.resolve('../router/v1/admin/usersShard.router'))
|
|
|
|
apply(api.staged)
|
|
coreRegistered = true
|
|
|
|
log.info('core registrations complete', {
|
|
streams: streams.length,
|
|
announceLegs: legs.size,
|
|
extensions: [...slots.keys()].filter(slotFilledBy),
|
|
})
|
|
}
|
|
|
|
/** Has registerCore() run? Read by tests, and by the loader's ordering assertion. */
|
|
const isCoreRegistered = () => coreRegistered
|
|
|
|
// Test-only: hand the process back. Registries are process-global by design
|
|
// (there is one core), so a test that registers has to be able to undo it.
|
|
//
|
|
// Slot DECLARATIONS survive, and only their fills are cleared: a slot is declared
|
|
// at require time by the router that owns the resource, and that require has
|
|
// already happened and will not happen again in this process. Clearing the map
|
|
// would leave a slot that nothing can re-declare. The cost is that a test filling
|
|
// the same slot twice stacks two routers inside it; no test reads through a slot
|
|
// router, so that is left rather than papered over with a rebuilt router that
|
|
// would no longer be the object users.router.js mounted.
|
|
function _reset() {
|
|
for (const entry of slots.values()) {
|
|
entry.filledBy = null
|
|
entry.specFile = null
|
|
}
|
|
streams.length = 0
|
|
streamOwners.clear()
|
|
legs.clear()
|
|
coreRegistered = false
|
|
}
|
|
|
|
module.exports = {
|
|
declareSlot,
|
|
hasSlot,
|
|
slotFilledBy,
|
|
filledSlots,
|
|
allStreams,
|
|
isValidStream,
|
|
personalStreams,
|
|
announceLegs,
|
|
announceLegIds,
|
|
announceLeg,
|
|
stage,
|
|
apply,
|
|
registerCore,
|
|
isCoreRegistered,
|
|
_reset,
|
|
}
|