`EVENTS_PLAN.md` Phase 10. Core registers its own `event.` triggers, records who took part, publishes a results table, and announces a post through the legs the news pipeline already uses. Events owns none of the delivery: a run says what happened and an operator's rule decides who is told, so email, the in-app inbox, push tickles, Discord and the town crier all arrive without anything in `events/` growing a second delivery path. **No route was added and nothing moved.** The whole surface is two more derived fields on a run — `participants` and `resultsPublishedAt` — and a zero-line `routes.manifest.json` diff proves it. Seven triggers: six at ceiling `authenticated` / audience `subscribers`, exactly where `news.post` sits, and `run.failed` at `admin` on both halves. Every one keys its cooldown on the RUN. Two rules seeded, both off, under a third one-shot key so a deployment that has already stamped the Team and news keys still gets them. **The phase's own defect was a promise nothing kept.** `EVENTS.md` §I says a rehearsal runs for real "with announcements ceilinged to `staff`" — but a ceiling is declared on the TRIGGER, and a rehearsal fires the same trigger as the real thing, so the moment this phase gave a run something to announce, rehearsing a published event would have mailed every subscriber. The emit envelope now takes an optional `ceiling` and the send-time G24 gate applies `meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse every rule rather than resolving to either. `MODULE_API_VERSION` stays 1.10.0, amended in place — `main` declares 1.9.0, so 1.10.0 has not shipped and the org lead's 2026-09-03 rule applies for the third time. Three defects the live walk found, none visible to a unit test: 1. **A channel that reported success while reaching nobody.** The seeded `run.started` rule named `push`, because §8.5 and the plan both do. Push delivery joins `notification_subscriptions`, only ever written for an id the preferences screen offered push for — and it offers push only for a registered STREAM. So the tickle went nowhere every time while `pushChannel.deliver` answered "tickle published". `event.run.started` is now a stream as well as a trigger; the other six are not. 2. **A trigger's `description` reaches a recipient.** It is the structural projection's `intro` fallback, so `run.failed`'s line ending "Staff-facing." put those words in an administrator's own inbox item. 3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The connector sends `CLIENT_FOUND_ROWS`, so a "was this new" flag would have counted every idempotent retried collect as a fresh participant. And one caught before it shipped: ranking with a session variable is wrong here, because `query()` takes a pool connection per call — the variable would be set on one connection and read on another. A window function needs no session state. ## Verification - `npm test --prefix server` — **1981 pass, 1 fail**, and that one (`botScore.test.js`) passes standalone at 18/18: a file-level flake under parallel load. Run with an empty `MODULES_DIR`, as CI does. - `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green. - Zero-line `routes.manifest.json` / `routes.guards.json` diff. - A live walk on a real rig: MariaDB, the site with no module, mailpit. The mail arrived, headed with the event's title and its start time in the shard's own zone; the rehearsal fired the same trigger and produced zero outbox rows where the real run produced three; `run.failed` reached the administrator's inbox and no player's; `core.announce.post` queued a second job without touching the news pipeline's back-pointer or `announced_at`; and `rankRun` and the upsert were run against real MariaDB 11. ## One thing for a reviewer, out of scope and not fixed **Every `#swagger.description` in this repo is truncated in the generated spec.** swagger-autogen does not honour a backslash-escaped apostrophe, so a description is cut at the first `\'` — 175 of the 177 in `server/src/router/**`. It is pre-existing and repo-wide. Only the one annotation this phase edits is fixed here (a typographic apostrophe), because otherwise this phase's own addition to it would be dead text. The rest wants its own change. - [x] AI-assisted: Claude Code (Opus 5). Docs: RunicGateway/docs#TBD. Co-Authored-By: Claude <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
126 lines
5.2 KiB
JavaScript
126 lines
5.2 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, run_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.
|
|
//
|
|
// `runId` is Phase 10's (EVENTS.md §J): a job an EVENT asked for, rather than
|
|
// the one a news publish enqueues. It changes nothing about how the job is
|
|
// dispatched, retried or rolled up — the whole point of reusing this pipeline is
|
|
// that an event announcement gets the legs, the backoff and the classification
|
|
// already written — and everything it does change is in the two places below
|
|
// that ask "whose job is this".
|
|
async function create(postId, legs = [], { runId = null } = {}) {
|
|
const res = await query('INSERT INTO announce_jobs (post_id, run_id) VALUES (?, ?)', [postId, runId])
|
|
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]
|
|
}
|
|
|
|
// **The post's OWN job, which is what `run_id IS NULL` means here.** A post may
|
|
// now have more than one — the news publish enqueued one, and an event linked the
|
|
// same post later — and every caller of this function is the post admin panel or
|
|
// its retry button, which are about the news announcement. Without the clause the
|
|
// panel would silently start rendering an event's job the moment one existed, and
|
|
// the retry button would retry that instead.
|
|
async function findByPostId(postId) {
|
|
const rows = await query(
|
|
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? AND run_id IS NULL 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 }
|