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>
113 lines
4.3 KiB
JavaScript
113 lines
4.3 KiB
JavaScript
// ── Announcement pipeline: SQL ─────────────────────────────────────────────
|
|
//
|
|
// Two tables since PR 4 (MODULE_SYSTEM.md §1.8): `announce_jobs` is one row per
|
|
// publish event, `announce_job_legs` one row per delivery leg of that job. The
|
|
// leg set is registered rather than fixed, so a leg is a stored VALUE now instead
|
|
// of a group of leg-prefixed columns — which is what lets a module bring its own
|
|
// leg without altering a core table.
|
|
//
|
|
// Every read returns the job with a `legs` array attached, so a caller never has
|
|
// to remember to fetch the second table.
|
|
|
|
const { query } = require('../../utils/db')
|
|
|
|
const COLS = 'id, post_id, status, created_at, updated_at'
|
|
const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
|
|
|
|
async function legsFor(jobIds) {
|
|
if (jobIds.length === 0) return new Map()
|
|
const marks = jobIds.map(() => '?').join(', ')
|
|
const rows = await query(
|
|
`SELECT ${LEG_COLS} FROM announce_job_legs WHERE job_id IN (${marks}) ORDER BY job_id, leg`,
|
|
jobIds,
|
|
)
|
|
const byJob = new Map(jobIds.map((id) => [id, []]))
|
|
for (const row of rows) byJob.get(row.job_id).push(row)
|
|
return byJob
|
|
}
|
|
|
|
async function attachLegs(jobs) {
|
|
const byJob = await legsFor(jobs.map((j) => j.id))
|
|
for (const job of jobs) job.legs = byJob.get(job.id) || []
|
|
return jobs
|
|
}
|
|
|
|
// Create a job and its leg rows in one go. `legs` is the registered leg id list —
|
|
// an empty list is legal and yields a job with nothing to deliver.
|
|
async function create(postId, legs = []) {
|
|
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
|
|
const jobId = Number(res.insertId)
|
|
if (legs.length > 0) {
|
|
const values = legs.map(() => '(?, ?)').join(', ')
|
|
await query(
|
|
`INSERT INTO announce_job_legs (job_id, leg) VALUES ${values}`,
|
|
legs.flatMap((leg) => [jobId, leg]),
|
|
)
|
|
}
|
|
return jobId
|
|
}
|
|
|
|
// Add any registered legs this job is missing. A job enqueued before a module was
|
|
// installed has no row for that module's leg, and without this it could never
|
|
// deliver one — the worker only ever sees rows that exist.
|
|
async function ensureLegs(jobId, legs = []) {
|
|
if (legs.length === 0) return
|
|
const values = legs.map(() => '(?, ?)').join(', ')
|
|
await query(
|
|
`INSERT IGNORE INTO announce_job_legs (job_id, leg) VALUES ${values}`,
|
|
legs.flatMap((leg) => [jobId, leg]),
|
|
)
|
|
}
|
|
|
|
async function findById(id) {
|
|
const rows = await query(`SELECT ${COLS} FROM announce_jobs WHERE id = ? LIMIT 1`, [id])
|
|
if (rows.length === 0) return null
|
|
return (await attachLegs(rows))[0]
|
|
}
|
|
|
|
async function findByPostId(postId) {
|
|
const rows = await query(
|
|
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
|
|
[postId],
|
|
)
|
|
if (rows.length === 0) return null
|
|
return (await attachLegs(rows))[0]
|
|
}
|
|
|
|
// Jobs with at least one leg that is due now: pending and either never scheduled
|
|
// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time. Returns
|
|
// whole jobs with every leg attached; the worker decides which legs to run, so
|
|
// this stays one query regardless of how many legs are registered.
|
|
async function findDue(now = new Date(), limit = 25) {
|
|
const rows = await query(
|
|
`SELECT ${COLS} FROM announce_jobs j
|
|
WHERE EXISTS (
|
|
SELECT 1 FROM announce_job_legs l
|
|
WHERE l.job_id = j.id
|
|
AND l.status = 'pending'
|
|
AND (l.next_attempt_at IS NULL OR l.next_attempt_at <= ?))
|
|
ORDER BY j.id ASC
|
|
LIMIT ?`,
|
|
[now, limit],
|
|
)
|
|
return attachLegs(rows)
|
|
}
|
|
|
|
// Update one leg's row. `leg` is a bound VALUE, not an interpolated column name —
|
|
// the reason the old leg allowlist that guarded that interpolation is gone. A
|
|
// module's leg id could not have passed it anyway.
|
|
async function updateLeg(jobId, leg, { status, attempts, lastError, nextAttemptAt }) {
|
|
await query(
|
|
`UPDATE announce_job_legs SET
|
|
status = ?, attempts = ?, last_error = ?, next_attempt_at = ?
|
|
WHERE job_id = ? AND leg = ?`,
|
|
[status, attempts, lastError ?? null, nextAttemptAt ?? null, jobId, leg],
|
|
)
|
|
}
|
|
|
|
async function setStatus(id, status) {
|
|
await query('UPDATE announce_jobs SET status = ? WHERE id = ?', [status, id])
|
|
}
|
|
|
|
module.exports = { create, ensureLegs, findById, findByPostId, findDue, updateLeg, setStatus }
|