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:
26
server/src/config/coreStreams.js
Normal file
26
server/src/config/coreStreams.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// ── Core's own push-notification streams ───────────────────────────────────
|
||||
//
|
||||
// What is left of config/notificationStreams.js once the shard-derived catalog
|
||||
// moved to config/shardStreams.js (MODULE_SYSTEM.md §1.8: push INFRASTRUCTURE is
|
||||
// core, the CATALOG is content). Exactly one stream is core's: `news.post` is
|
||||
// produced by the website's own posts path, not by any game feed.
|
||||
//
|
||||
// Registered through modules/registries.js like any module's, and read back
|
||||
// through it — nothing imports this file to get "the catalog", because the
|
||||
// catalog is core's plus every module's.
|
||||
//
|
||||
// The payload that ever leaves the server is a CONTENT-FREE tickle
|
||||
// ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content
|
||||
// over the authenticated API (docs/android/PLAN.md §11).
|
||||
|
||||
const STREAMS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
label: 'News posts',
|
||||
description: 'New news / Five-on-Friday / newsletter posts.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { STREAMS }
|
||||
@@ -1,7 +1,14 @@
|
||||
// ── Push-notification stream catalog + event → stream mapping ───────────────
|
||||
// ── Shard-derived push streams + event → stream mapping ────────────────────
|
||||
//
|
||||
// The single source of truth for which streams a user can subscribe to, and how
|
||||
// a shard event maps onto them. Two families:
|
||||
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8 named
|
||||
// config/notificationStreams.js as one of the three genuinely entangled files:
|
||||
// most of its catalog and all of `mapShardEvent` are shard-derived, and it reads
|
||||
// `PUBLIC_KINDS` out of utils/shardBroadcast. PR 4 split it — core's one stream
|
||||
// is config/coreStreams.js, and everything shard-shaped is here, in a file that
|
||||
// moves to module-uo whole in Phase 3. Nothing in core imports it except
|
||||
// modules/registries.js's registerCore(), which is the one line Phase 3 deletes.
|
||||
//
|
||||
// Two families:
|
||||
// • public / opt-in — no linked game account required; delivered to every
|
||||
// subscriber. Drawn ONLY from the SSE public allowlist
|
||||
// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
|
||||
@@ -17,17 +24,7 @@
|
||||
|
||||
const { PUBLIC_KINDS } = require('../utils/shardBroadcast')
|
||||
|
||||
// The subscribable catalog. `news.post` is produced by the website's own posts
|
||||
// path (not the shard feed) — see utils/pushDispatch — so it has no mapShardEvent
|
||||
// case; every other stream is shard-derived below.
|
||||
const STREAMS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
label: 'News posts',
|
||||
description: 'New news / Five-on-Friday / newsletter posts.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'server.status',
|
||||
label: 'Server up / down',
|
||||
@@ -79,8 +76,10 @@ const STREAMS = [
|
||||
},
|
||||
]
|
||||
|
||||
const STREAM_IDS = new Set(STREAMS.map((s) => s.id))
|
||||
const isValidStream = (id) => STREAM_IDS.has(id)
|
||||
// The owner-keyed subset, needed by mapShardEvent's public-safety filter below.
|
||||
// Derived from this file's own catalog rather than read back out of the registry:
|
||||
// the filter is about THESE streams, and a module must not be able to weaken it
|
||||
// by registering something that happens to share an id.
|
||||
const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id))
|
||||
|
||||
// Per-process transition state so full-state upserts (champ.update / city.update
|
||||
@@ -162,7 +161,12 @@ function mapShardEvent(event, tracker = defaultTracker) {
|
||||
// they are exempt from the public allowlist (that is the whole point of the
|
||||
// owner-keyed split). This guarantees a sensitive kind can never leak publicly
|
||||
// even if a future mapping case is added carelessly.
|
||||
//
|
||||
// This filter, the kinds it reads and the streams it protects now all live in
|
||||
// one file and move together — the reason PR 4 dropped the contract's
|
||||
// `mapEvent` half rather than leaving the mapping in core and the catalog in a
|
||||
// module (MODULE_API.md §2.4).
|
||||
return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind)))
|
||||
}
|
||||
|
||||
module.exports = { STREAMS, isValidStream, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
module.exports = { STREAMS, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
Reference in New Issue
Block a user