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>
82 lines
3.5 KiB
JavaScript
82 lines
3.5 KiB
JavaScript
// ── Announcement pipeline: pure logic ──────────────────────────────────────
|
|
//
|
|
// No DB, no network — just the LEG-AGNOSTIC decisions the worker and model make,
|
|
// kept here so they are unit-testable in isolation (server/test/announceJobs.test.js):
|
|
// • scheduleAfter — exponential backoff schedule + the attempt cap
|
|
// • rollupStatus — derive the parent job status from its legs
|
|
// • legError — squeeze a client result into one error line
|
|
// • baseUrl / articleUrl — the public link an announcement carries
|
|
//
|
|
// What used to be here and is not any more: `buildTownCrierText`,
|
|
// `classifyTownCrier` and `classifyDiscord`. A leg's own text-building and result
|
|
// classification belong to the leg, and a leg is a registration now
|
|
// (MODULE_SYSTEM.md §1.8) — they live in utils/shardAnnounce.js and
|
|
// utils/discordAnnounce.js. This file is what every leg shares.
|
|
|
|
// Backoff between retries, indexed by attempts-so-far. Six attempts spread over
|
|
// ~a couple of hours; after the last one a leg is marked failed and surfaced in
|
|
// the post's admin panel. Shared by every leg.
|
|
const BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000]
|
|
const MAX_ATTEMPTS = BACKOFF_MS.length
|
|
|
|
// The site's public base, used to build the link an announcement carries.
|
|
function baseUrl() {
|
|
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
|
}
|
|
|
|
// The public link that goes in the announcement. News has no per-post route
|
|
// (App.jsx only has the /site/news list), so we link the list — matches the
|
|
// pre-pipeline Discord announce behavior.
|
|
function articleUrl(base) {
|
|
return `${String(base || '').replace(/\/+$/, '')}/site/news`
|
|
}
|
|
|
|
// Every leg's client returns { ok, status, data, error }. Squeeze a failure into
|
|
// the one line stored in announce_job_legs.last_error and shown in the panel.
|
|
function legError(result) {
|
|
if (!result) return 'no response'
|
|
if (result.status) {
|
|
return result.data && result.data.message
|
|
? `${result.status}: ${result.data.message}`
|
|
: result.error || `status ${result.status}`
|
|
}
|
|
return result.error || 'request failed'
|
|
}
|
|
|
|
// Given the number of attempts already made (>= 1), how long to wait before the
|
|
// next one — or null when the cap is reached and the leg should be failed.
|
|
function scheduleAfter(attempts) {
|
|
if (attempts >= MAX_ATTEMPTS) return null
|
|
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]
|
|
}
|
|
|
|
// Parent job status derived from its leg statuses:
|
|
// done — every leg delivered
|
|
// failed — every leg gave up
|
|
// partial — at least one leg reached a terminal state without all of them
|
|
// agreeing (some still pending/retrying, or a mix of done and failed)
|
|
// pending — no leg is terminal yet
|
|
//
|
|
// Takes the list of leg statuses rather than two named arguments, because the leg
|
|
// set is registered rather than fixed (MODULE_SYSTEM.md §1.8). No legs at all
|
|
// rolls up `done`: with nothing registered there is nothing left to deliver, and
|
|
// leaving such jobs `pending` would pile up rows the worker never touches.
|
|
function rollupStatus(statuses) {
|
|
const list = Array.isArray(statuses) ? statuses : []
|
|
const terminal = (s) => s === 'done' || s === 'failed'
|
|
if (list.every((s) => s === 'done')) return 'done'
|
|
if (list.every((s) => s === 'failed')) return 'failed'
|
|
if (list.some(terminal)) return 'partial'
|
|
return 'pending'
|
|
}
|
|
|
|
module.exports = {
|
|
MAX_ATTEMPTS,
|
|
BACKOFF_MS,
|
|
baseUrl,
|
|
articleUrl,
|
|
legError,
|
|
scheduleAfter,
|
|
rollupStatus,
|
|
}
|