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:
206
server/test/announceLegs.test.js
Normal file
206
server/test/announceLegs.test.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// ── The announcement pipeline, once legs became registrations ──────────────
|
||||
//
|
||||
// Phase 2 PR 4 (docs/website/MODULE_SYSTEM.md §1.8): `announce_jobs`' two
|
||||
// hardcoded leg column groups became `announce_job_legs` rows, and which legs
|
||||
// exist is what modules/registries.js answers. These tests are about that
|
||||
// property specifically — that nothing in the worker or the model knows the word
|
||||
// "towncrier", and a leg nobody registered is handled rather than assumed away.
|
||||
//
|
||||
// Point the DB at a closed port BEFORE requiring anything; every DB call the
|
||||
// model makes is stubbed.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const announceJobs = require('../src/model/announceJobs/announceJobs.model')
|
||||
const announceDb = require('../src/model/announceJobs/announceJobs.db')
|
||||
const worker = require('../src/utils/announceWorker')
|
||||
const posts = require('../src/model/posts/posts.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originals = {
|
||||
create: announceDb.create,
|
||||
findById: announceDb.findById,
|
||||
findByPostId: announceDb.findByPostId,
|
||||
findDue: announceDb.findDue,
|
||||
updateLeg: announceDb.updateLeg,
|
||||
ensureLegs: announceDb.ensureLegs,
|
||||
setStatus: announceDb.setStatus,
|
||||
linkAnnounceJob: posts.linkAnnounceJob,
|
||||
markAnnounced: posts.markAnnounced,
|
||||
getById: posts.getById,
|
||||
}
|
||||
afterEach(() => Object.assign(announceDb, {
|
||||
create: originals.create,
|
||||
findById: originals.findById,
|
||||
findByPostId: originals.findByPostId,
|
||||
findDue: originals.findDue,
|
||||
updateLeg: originals.updateLeg,
|
||||
ensureLegs: originals.ensureLegs,
|
||||
setStatus: originals.setStatus,
|
||||
}) && Object.assign(posts, {
|
||||
linkAnnounceJob: originals.linkAnnounceJob,
|
||||
markAnnounced: originals.markAnnounced,
|
||||
getById: originals.getById,
|
||||
}))
|
||||
|
||||
// Two legs that record what they were asked to do, standing in for core's
|
||||
// discord and a module's own.
|
||||
function fakeLegs() {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
a: { leg: 'discord', label: 'Discord #news', dispatch: async (p) => { calls.push(['discord', p.id]); return { ok: true } }, classify: (r) => (r.ok ? { outcome: 'done' } : { outcome: 'retry', error: 'x' }) },
|
||||
b: { leg: 'rust.motd', label: 'Server MOTD', dispatch: async (p) => { calls.push(['rust.motd', p.id]); return { ok: false, status: 503 } }, classify: (r) => (r.ok ? { outcome: 'done' } : { outcome: 'retry', error: 'down' }) },
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => registries._reset())
|
||||
|
||||
function register(...legs) {
|
||||
const api = registries.stage('core')
|
||||
for (const l of legs) api.registerAnnounceLeg(l)
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
// ── Enqueue ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('enqueue creates one leg row per REGISTERED leg, whatever they are', () => {
|
||||
const legs = fakeLegs()
|
||||
register(legs.a, legs.b)
|
||||
|
||||
let created = null
|
||||
announceDb.create = async (postId, ids) => { created = { postId, ids }; return 42 }
|
||||
posts.linkAnnounceJob = async () => {}
|
||||
|
||||
return announceJobs.enqueue(9).then((jobId) => {
|
||||
assert.equal(jobId, 42)
|
||||
assert.deepEqual(created, { postId: 9, ids: ['discord', 'rust.motd'] })
|
||||
})
|
||||
})
|
||||
|
||||
test('with no legs registered, a job is created with none — and rolls up done', async () => {
|
||||
let created = null
|
||||
announceDb.create = async (postId, ids) => { created = { postId, ids }; return 1 }
|
||||
posts.linkAnnounceJob = async () => {}
|
||||
await announceJobs.enqueue(9)
|
||||
assert.deepEqual(created.ids, [])
|
||||
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 9, status: 'pending', legs: [] })
|
||||
const statuses = []
|
||||
announceDb.setStatus = async (_id, s) => statuses.push(s)
|
||||
posts.markAnnounced = async () => {}
|
||||
const job = await announceJobs.refreshStatus(1)
|
||||
assert.equal(job.status, 'done')
|
||||
assert.deepEqual(statuses, ['done'])
|
||||
})
|
||||
|
||||
// ── The worker dispatches through the registration ─────────────────────────
|
||||
|
||||
test('the worker dispatches each due leg through whatever registered it', async () => {
|
||||
const legs = fakeLegs()
|
||||
register(legs.a, legs.b)
|
||||
posts.getById = async () => ({ id: 5, title: 't', excerpt: 'e', body: null })
|
||||
|
||||
const updates = []
|
||||
announceDb.findDue = async () => [{
|
||||
id: 1,
|
||||
post_id: 5,
|
||||
status: 'pending',
|
||||
legs: [
|
||||
{ leg: 'discord', status: 'pending', attempts: 0, next_attempt_at: null },
|
||||
{ leg: 'rust.motd', status: 'pending', attempts: 0, next_attempt_at: null },
|
||||
],
|
||||
}]
|
||||
announceDb.updateLeg = async (jobId, leg, fields) => updates.push([leg, fields.status])
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'pending', legs: [] })
|
||||
announceDb.setStatus = async () => {}
|
||||
posts.markAnnounced = async () => {}
|
||||
|
||||
await worker.tick(new Date())
|
||||
|
||||
assert.deepEqual(legs.calls, [['discord', 5], ['rust.motd', 5]])
|
||||
// discord delivered; rust.motd got a 503, so it is rescheduled, not failed.
|
||||
assert.deepEqual(updates, [['discord', 'done'], ['rust.motd', 'pending']])
|
||||
})
|
||||
|
||||
test('a leg row nobody registers any more is left alone, not failed', async () => {
|
||||
// Its module was uninstalled. Failing it would roll the job up terminal on the
|
||||
// strength of a leg that no longer exists, and reinstalling should resume it.
|
||||
register(fakeLegs().a)
|
||||
let touched = false
|
||||
announceDb.updateLeg = async () => { touched = true }
|
||||
posts.getById = async () => { throw new Error('must not even look up the post') }
|
||||
|
||||
await worker.processLeg({ id: 1, post_id: 5, legs: [{ leg: 'gone.leg', status: 'pending', attempts: 0 }] }, 'gone.leg')
|
||||
assert.equal(touched, false)
|
||||
})
|
||||
|
||||
test('isLegDue reads the row, not a leg-prefixed column', () => {
|
||||
const past = new Date(Date.now() - 1000)
|
||||
const future = new Date(Date.now() + 60_000)
|
||||
assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: null }, new Date()), true)
|
||||
assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: past }, new Date()), true)
|
||||
assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: future }, new Date()), false)
|
||||
assert.equal(worker.isLegDue({ status: 'done', next_attempt_at: null }, new Date()), false)
|
||||
assert.equal(worker.isLegDue({ status: 'failed', next_attempt_at: null }, new Date()), false)
|
||||
})
|
||||
|
||||
// ── Retry, and the label the panel renders ─────────────────────────────────
|
||||
|
||||
test('resetLeg refuses a leg nobody registered', async () => {
|
||||
register(fakeLegs().a)
|
||||
await assert.rejects(() => announceJobs.resetLeg(5, 'rust.motd'), /unknown announce leg/)
|
||||
})
|
||||
|
||||
test('resetLeg creates the row when a module was installed after the job', async () => {
|
||||
// Otherwise the retry button could never deliver a newly-installed module's
|
||||
// leg on an already-announced post: the worker only sees rows that exist.
|
||||
const legs = fakeLegs()
|
||||
register(legs.a, legs.b)
|
||||
const ensured = []
|
||||
announceDb.findByPostId = async () => ({ id: 1, post_id: 5, status: 'partial', legs: [{ leg: 'discord', status: 'done' }] })
|
||||
announceDb.ensureLegs = async (jobId, ids) => ensured.push([jobId, ids])
|
||||
announceDb.updateLeg = async () => {}
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'partial', legs: [{ leg: 'discord', status: 'done' }, { leg: 'rust.motd', status: 'pending' }] })
|
||||
announceDb.setStatus = async () => {}
|
||||
|
||||
const job = await announceJobs.resetLeg(5, 'rust.motd')
|
||||
assert.deepEqual(ensured, [[1, ['rust.motd']]])
|
||||
assert.deepEqual(job.legs.map((l) => l.label), ['Discord #news', 'Server MOTD'])
|
||||
})
|
||||
|
||||
test('a leg’s label comes from its registration, and an orphan keeps its id', async () => {
|
||||
register(fakeLegs().a)
|
||||
announceDb.findByPostId = async () => ({
|
||||
id: 1,
|
||||
post_id: 5,
|
||||
status: 'partial',
|
||||
legs: [{ leg: 'discord', status: 'done' }, { leg: 'gone.leg', status: 'pending' }],
|
||||
})
|
||||
const job = await announceJobs.getByPostId(5)
|
||||
assert.deepEqual(job.legs.map((l) => [l.leg, l.label]), [
|
||||
['discord', 'Discord #news'],
|
||||
['gone.leg', 'gone.leg'],
|
||||
])
|
||||
})
|
||||
|
||||
// ── recordOutcome reads attempts off the row ───────────────────────────────
|
||||
|
||||
test('recordOutcome takes the attempt count from the leg row', async () => {
|
||||
register(fakeLegs().a)
|
||||
const updates = []
|
||||
announceDb.updateLeg = async (jobId, leg, fields) => updates.push(fields)
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'pending', legs: [{ leg: 'discord', status: 'pending' }] })
|
||||
announceDb.setStatus = async () => {}
|
||||
|
||||
const job = { id: 1, post_id: 5, legs: [{ leg: 'discord', status: 'pending', attempts: 2 }] }
|
||||
await announceJobs.recordOutcome(job, 'discord', { outcome: 'retry', error: 'nope' })
|
||||
assert.equal(updates[0].attempts, 3)
|
||||
assert.ok(updates[0].nextAttemptAt instanceof Date)
|
||||
})
|
||||
Reference in New Issue
Block a user