feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
`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)
This commit is contained in:
162
server/src/events/participants.js
Normal file
162
server/src/events/participants.js
Normal file
@@ -0,0 +1,162 @@
|
||||
// ── Recording who took part ────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The twin of
|
||||
// `events/ledger.js`: that file records what a run did to the world, this one
|
||||
// records who it happened to.
|
||||
//
|
||||
// **Participants ride the SAME envelope resources do** (org lead, 2026-09-04). An
|
||||
// action answers `{ ok: true, participants: [...] }` and the runner writes them
|
||||
// beside the resources, on the same two success shapes, through the same
|
||||
// classify → record path. There is no `ctx.events.participants` API and no route:
|
||||
// a second write path into a run core is mid-tick on would be a second thing that
|
||||
// can race the claim, for a caller that does not exist until a module can source
|
||||
// the data at all (Phase 11's plugin-side participation ledger; Phase 12's
|
||||
// collect step is the first consumer).
|
||||
//
|
||||
// **Core cannot source a participant and does not try.** §J: `member_key` is
|
||||
// module-opaque, `user_id` is filled in by whoever knows the link table. For
|
||||
// module-uo that is `shard_links`; for another game it is something else, and a
|
||||
// core that guessed would be one game's identity model compiled into core. So a
|
||||
// module reports both halves, or reports the key alone and the row stays
|
||||
// anonymous — which is the honest record of an unlinked player who turned up.
|
||||
//
|
||||
// **A bad entry is dropped, never a retry.** Exactly `ledger.normalise`'s
|
||||
// posture and for exactly its reason: a malformed participant will be just as
|
||||
// malformed on the second attempt, and failing the step would re-dispatch a
|
||||
// world write that already happened. Rejections are logged and surfaced on the
|
||||
// run log so an author can see what their module sent.
|
||||
|
||||
const participantsDb = require('../model/events/eventRunParticipants.db')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// Bounded to the column, and refused rather than truncated: a truncated member
|
||||
// key is a different participant, and under `uq_evpart_member` it would silently
|
||||
// merge two people into one row.
|
||||
const MAX_MEMBER_KEY = 190
|
||||
|
||||
// The most one step may report. A run's participants are people, and a step
|
||||
// answering with a hundred thousand of them is a module bug rather than a very
|
||||
// popular event — one that would otherwise spend a tick's whole budget on
|
||||
// inserts while holding the step's claim. `MAX_AUDIENCE` in the engagement
|
||||
// engine is 5000 for the same class of reason and this matches it deliberately:
|
||||
// the two bound the same thing, a list of users one call may assert.
|
||||
const MAX_PER_STEP = 5000
|
||||
|
||||
/**
|
||||
* Turn one entry of a module's `participants` array into a row, or say why not.
|
||||
*/
|
||||
function normalise(entry, actionId) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return { ok: false, reason: `${actionId} reported a participant that is not an object` }
|
||||
}
|
||||
|
||||
const memberKey = String(entry.memberKey === undefined || entry.memberKey === null ? '' : entry.memberKey)
|
||||
if (!memberKey || memberKey.length > MAX_MEMBER_KEY) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad memberKey "${entry.memberKey}"` }
|
||||
}
|
||||
|
||||
// Optional, and checked rather than coerced. A `userId` is a foreign key into
|
||||
// `users`, so a module that passed a character serial here would either fail
|
||||
// the insert or — worse, if the number happened to be a real user — attribute
|
||||
// somebody else's attendance to a stranger.
|
||||
let userId = null
|
||||
if (entry.userId !== undefined && entry.userId !== null) {
|
||||
if (!Number.isInteger(entry.userId) || entry.userId < 1) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad userId "${entry.userId}"` }
|
||||
}
|
||||
userId = entry.userId
|
||||
}
|
||||
|
||||
// `score` is optional and defaults to 0 — a run that only records attendance
|
||||
// is a run where everybody scored nothing, which is a true statement and a
|
||||
// renderable table. Non-finite is refused rather than coerced: `NaN` written
|
||||
// into a DECIMAL would either throw at the driver or land as 0, and a 0 that
|
||||
// meant "the module sent nonsense" is indistinguishable from an honest zero.
|
||||
let score = 0
|
||||
if (entry.score !== undefined && entry.score !== null) {
|
||||
const n = Number(entry.score)
|
||||
if (!Number.isFinite(n)) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad score "${entry.score}"` }
|
||||
}
|
||||
score = n
|
||||
}
|
||||
|
||||
let joinedAt = null
|
||||
if (entry.joinedAt !== undefined && entry.joinedAt !== null) {
|
||||
const at = entry.joinedAt instanceof Date ? entry.joinedAt : new Date(entry.joinedAt)
|
||||
if (Number.isNaN(at.getTime())) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad joinedAt "${entry.joinedAt}"` }
|
||||
}
|
||||
joinedAt = at
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
row: {
|
||||
memberKey,
|
||||
userId,
|
||||
score,
|
||||
// Opaque, exactly like a resource's payload: core stores it and never
|
||||
// reads it. Anything but an object is dropped rather than refused —
|
||||
// `meta` is decoration on a row whose identity is already valid, and
|
||||
// losing an event's whole attendance over a stray string would be the
|
||||
// wrong trade.
|
||||
meta: entry.meta && typeof entry.meta === 'object' && !Array.isArray(entry.meta) ? entry.meta : null,
|
||||
joinedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what a module said about who took part.
|
||||
*
|
||||
* Answers `{ recorded, rejected }`. Never throws, for `recordAnswer`'s reason: a
|
||||
* step that changed the world has changed it, and a bookkeeping failure must not
|
||||
* become a retry of a world write.
|
||||
*/
|
||||
async function recordAnswer({ run, step, action, participants }) {
|
||||
const out = { recorded: 0, rejected: [] }
|
||||
const list = Array.isArray(participants) ? participants : []
|
||||
if (!list.length) return out
|
||||
|
||||
if (list.length > MAX_PER_STEP) {
|
||||
// Refused whole rather than truncated. Half a leaderboard silently cut at
|
||||
// five thousand is worse than none: the table would look complete and be
|
||||
// wrong, and nothing downstream could tell.
|
||||
const reason = `${action.id} reported ${list.length} participants, more than the ${MAX_PER_STEP} one step may`
|
||||
log.warn('event participants refused', { run: run.id, step: step.id, reason })
|
||||
return { recorded: 0, rejected: [reason] }
|
||||
}
|
||||
|
||||
// **Deduplicated in memory before the write.** One step reporting the same
|
||||
// member twice is a module bug, and letting both reach the upsert would make
|
||||
// the LAST one win silently. Refusing the step would be worse — the other
|
||||
// ninety-nine participants are fine — so the first wins and the duplicate is
|
||||
// named, which is a thing an author can act on.
|
||||
const seen = new Set()
|
||||
|
||||
for (const entry of list) {
|
||||
const parsed = normalise(entry, action.id)
|
||||
if (!parsed.ok) {
|
||||
out.rejected.push(parsed.reason)
|
||||
log.warn('event participant rejected', { run: run.id, step: step.id, reason: parsed.reason })
|
||||
continue
|
||||
}
|
||||
if (seen.has(parsed.row.memberKey)) {
|
||||
out.rejected.push(`${action.id} reported "${parsed.row.memberKey}" twice in one step`)
|
||||
continue
|
||||
}
|
||||
seen.add(parsed.row.memberKey)
|
||||
try {
|
||||
await participantsDb.record({ runId: run.id, ...parsed.row })
|
||||
out.recorded += 1
|
||||
} catch (err) {
|
||||
out.rejected.push(err.message)
|
||||
log.error('event participant insert failed', { run: run.id, step: step.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { normalise, recordAnswer, MAX_MEMBER_KEY, MAX_PER_STEP }
|
||||
Reference in New Issue
Block a user