feat(modules): the three de-entanglement registries, with core as the registrant
All checks were successful
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 1m39s
PR Checks / bot-install (pull_request) Successful in 8m49s

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:
2026-08-10 17:47:59 -05:00
parent bd749d4f1f
commit 6195c76d61
36 changed files with 1948 additions and 465 deletions

View File

@@ -33,6 +33,7 @@ const path = require('path')
const { MODULE_API_VERSION } = require('./version')
const semver = require('./semver')
const registries = require('./registries')
const { splitStatements } = require('../utils/sqlStatements')
const log = require('../utils/logger')('modules')
@@ -51,10 +52,10 @@ const MANIFEST_KEYS = new Set([
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
])
// Extension slots core declares (§2.4). Only core may declare one; a module may
// only fill one. Validation rejects a manifest naming a slot that does not
// exist — `registerExtension` itself arrives with PR 4.
const CORE_SLOTS = new Set(['admin.users.detail'])
// Extension slots are declared by core, at require time, in the router that owns
// the resource (registries.declareSlot). The loader asks the registry which exist
// rather than keeping a list, for the same reason the prefix check probes the
// live tier routers: a second copy of the answer is a copy that drifts.
// id → record. Populated by load(), read by list().
const modules = new Map()
@@ -144,9 +145,9 @@ function buildApi(record) {
if (record.called.has(name)) throw new Error(`${name}() called twice`)
record.called.add(name)
}
// PR 4 brings the three de-entanglement registries and PR 5 the boot hooks.
// They throw rather than no-op: an accepting stub would let a module believe
// it had registered something and fail silently at the far end.
// PR 5 brings the boot hooks. They throw rather than no-op: an accepting stub
// would let a module believe it had registered something and fail silently at
// the far end.
const notYet = (name, pr) => () => {
throw new Error(`${name}: not available until phase 2 PR ${pr}`)
}
@@ -163,9 +164,22 @@ function buildApi(record) {
}
}
},
registerExtension: notYet('registerExtension', 4),
registerNotificationStreams: notYet('registerNotificationStreams', 4),
registerAnnounceLeg: notYet('registerAnnounceLeg', 4),
// The three de-entanglement registries (§2.4). They live in registries.js
// rather than here because core registers through the same staging area, and
// core has no `api` object.
//
// These STAGE. Nothing a module registers is visible to core until the
// second pass commits it, for the reason the second pass exists at all: a
// module that throws halfway through register(), or fails checkDeclared
// after it, must leave nothing behind. A half-registered stream catalog
// would be worse than a missing one — it would be a subscribable stream
// nothing will ever publish to.
registerExtension: record.staged.registerExtension,
registerNotificationStreams(streams) {
once('registerNotificationStreams')
record.staged.registerNotificationStreams(streams)
},
registerAnnounceLeg: record.staged.registerAnnounceLeg,
onBoot: notYet('onBoot', 5),
onShutdown: notYet('onShutdown', 5),
}
@@ -317,7 +331,7 @@ function readManifest(dir, id, tierRouters) {
}
for (const slot of manifest.extensions || []) {
if (!CORE_SLOTS.has(slot)) throw new Error(`unknown extension slot "${slot}"`)
if (!registries.hasSlot(slot)) throw new Error(`unknown extension slot "${slot}"`)
}
if (manifest.schema && !manifest.purge) {
@@ -392,6 +406,7 @@ function load(tierRouters) {
dir,
manifest: null,
routes: { public: new Map(), admin: new Map(), player: new Map() },
staged: registries.stage(id),
tables: new Set(),
called: new Set(),
state: 'installed',
@@ -433,7 +448,20 @@ function load(tierRouters) {
// prefix would be told it collided with core, naming the wrong culprit, and
// the module-versus-module check below it could never be reached.
for (const record of modules.values()) {
if (record.state === 'registered') mount(record, tierRouters)
if (record.state !== 'registered') continue
try {
// Commit what this module staged. Collisions with core or with an earlier
// module surface here, in scan order, and cost only this module.
registries.apply(record.staged.staged)
} catch (err) {
record.state = 'startup_failed'
record.reason = err.message
log.error(`module "${record.id}" failed to register — continuing without it`, {
reason: err.message,
})
continue // unmounted, exactly like a validation failure in the first pass
}
mount(record, tierRouters)
}
}