feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
Some checks failed
PR Checks / client-build (pull_request) Successful in 45s
PR Checks / server-tests (pull_request) Failing after 5m47s
PR Checks / bot-tests (pull_request) Successful in 8m27s

`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:
2026-09-04 13:06:46 -05:00
parent d4516739b4
commit 7d3d6d5abd
36 changed files with 2960 additions and 39 deletions

View File

@@ -0,0 +1,193 @@
// ── Who took part (EVENTS_PLAN.md Phase 10) ────────────────────────────────
//
// The write half of `event_run_participants`, and it is `events/ledger.js`'s
// twin in every respect that matters — same envelope, same two success shapes,
// same fail-open-on-a-bad-entry posture. The properties worth a test are the
// ones where getting it wrong is silent:
//
// • a module's bad entry is DROPPED, never a retry — a retried step is a
// re-dispatched world write, which is a far worse outcome than one missing
// name on a leaderboard
// • a `userId` is checked rather than coerced, because the column is a foreign
// key into `users` and a character serial that happened to be a real user id
// would attribute somebody's attendance to a stranger
// • one step may not report the same member twice, and the duplicate is NAMED
// rather than silently letting the last one win
// • the classifier carries `participants` on both success shapes, including
// `await: 'human'` — a cue's confirm finishes the step without a second
// dispatch, so that is the only moment its participants can be recorded
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const participants = require('../src/events/participants')
const participantsDb = require('../src/model/events/eventRunParticipants.db')
const { classify } = require('../src/events/dispatch')
const db = require('../src/utils/db')
after(() => db.close())
const ACTION = { id: 'uo.participants.collect' }
const RUN = { id: 42 }
const STEP = { id: 7, phase: 'main' }
let written
const original = participantsDb.record
beforeEach(() => {
written = []
participantsDb.record = async (row) => {
written.push(row)
}
})
after(() => {
participantsDb.record = original
})
// ── normalise ──────────────────────────────────────────────────────────────
test('a member key is required and is the only required field', () => {
const ok = participants.normalise({ memberKey: 'darrow' }, ACTION.id)
assert.equal(ok.ok, true)
assert.equal(ok.row.memberKey, 'darrow')
assert.equal(ok.row.userId, null)
assert.equal(ok.row.score, 0)
assert.equal(ok.row.meta, null)
assert.equal(ok.row.joinedAt, null)
})
test('every bad shape is refused, and each names what was wrong', () => {
const bad = [
[null, /not an object/],
['darrow', /not an object/],
[[], /not an object/],
[{}, /bad memberKey/],
[{ memberKey: '' }, /bad memberKey/],
[{ memberKey: 'x'.repeat(participants.MAX_MEMBER_KEY + 1) }, /bad memberKey/],
[{ memberKey: 'd', userId: 0 }, /bad userId/],
[{ memberKey: 'd', userId: -3 }, /bad userId/],
[{ memberKey: 'd', userId: 1.5 }, /bad userId/],
[{ memberKey: 'd', userId: '4' }, /bad userId/],
[{ memberKey: 'd', score: 'lots' }, /bad score/],
[{ memberKey: 'd', score: Number.NaN }, /bad score/],
[{ memberKey: 'd', score: Infinity }, /bad score/],
[{ memberKey: 'd', joinedAt: 'yesterday' }, /bad joinedAt/],
]
for (const [entry, pattern] of bad) {
const parsed = participants.normalise(entry, ACTION.id)
assert.equal(parsed.ok, false, `${JSON.stringify(entry)} should be refused`)
assert.match(parsed.reason, pattern)
}
})
test('a userId that is a real integer rides through; a serial-shaped string does not', () => {
assert.equal(participants.normalise({ memberKey: 'd', userId: 12 }, ACTION.id).row.userId, 12)
assert.equal(participants.normalise({ memberKey: 'd', userId: '0x4001' }, ACTION.id).ok, false)
})
test('a negative score is legal — a game may score downward', () => {
assert.equal(participants.normalise({ memberKey: 'd', score: -40 }, ACTION.id).row.score, -40)
})
test('meta that is not an object is dropped rather than refusing the whole participant', () => {
// Decoration on a row whose identity is already valid. Losing an event's
// attendance over a stray string would be the wrong trade.
const parsed = participants.normalise({ memberKey: 'd', meta: 'warrior' }, ACTION.id)
assert.equal(parsed.ok, true)
assert.equal(parsed.row.meta, null)
assert.deepEqual(participants.normalise({ memberKey: 'd', meta: { c: 'mage' } }, ACTION.id).row.meta, { c: 'mage' })
})
// ── recordAnswer ───────────────────────────────────────────────────────────
test('nothing reported is not an error and writes nothing', async () => {
assert.deepEqual(await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: undefined }), {
recorded: 0,
rejected: [],
})
assert.equal(written.length, 0)
})
test('a bad entry never fails the step, and the good ones beside it still land', async () => {
const out = await participants.recordAnswer({
run: RUN,
step: STEP,
action: ACTION,
participants: [{ memberKey: 'darrow', score: 12 }, { userId: 4 }, { memberKey: 'marisol' }],
})
assert.equal(out.recorded, 2)
assert.equal(out.rejected.length, 1)
assert.match(out.rejected[0], /bad memberKey/)
assert.deepEqual(written.map((w) => w.memberKey), ['darrow', 'marisol'])
})
test('one step reporting the same member twice writes one row and names the duplicate', async () => {
const out = await participants.recordAnswer({
run: RUN,
step: STEP,
action: ACTION,
participants: [{ memberKey: 'darrow', score: 12 }, { memberKey: 'darrow', score: 99 }],
})
assert.equal(out.recorded, 1)
assert.match(out.rejected[0], /twice in one step/)
assert.equal(written.length, 1)
assert.equal(written[0].score, 12)
})
test('more participants than one step may report is refused WHOLE, not truncated', async () => {
// Half a leaderboard silently cut is worse than none: the table would look
// complete and be wrong, and nothing downstream could tell.
const many = Array.from({ length: participants.MAX_PER_STEP + 1 }, (_, i) => ({ memberKey: `m${i}` }))
const out = await participants.recordAnswer({ run: RUN, step: STEP, action: ACTION, participants: many })
assert.equal(out.recorded, 0)
assert.equal(written.length, 0)
assert.match(out.rejected[0], /more than the/)
})
test('a write that throws is recorded as a rejection rather than becoming the step\'s control flow', async () => {
participantsDb.record = async () => {
throw new Error('deadlock found when trying to get lock')
}
const out = await participants.recordAnswer({
run: RUN,
step: STEP,
action: ACTION,
participants: [{ memberKey: 'darrow' }],
})
assert.equal(out.recorded, 0)
assert.match(out.rejected[0], /deadlock/)
})
test('the run id is bound by the caller and never taken from the entry', async () => {
await participants.recordAnswer({
run: RUN,
step: STEP,
action: ACTION,
// A module cannot record somebody into another run by saying so.
participants: [{ memberKey: 'darrow', runId: 99999 }],
})
assert.equal(written[0].runId, RUN.id)
})
// ── the classifier carries them ────────────────────────────────────────────
test('participants ride back on BOTH success shapes, and default to an empty list', () => {
assert.deepEqual(classify({ ok: true, participants: [{ memberKey: 'd' }] }, 'a').participants, [{ memberKey: 'd' }])
assert.deepEqual(
classify({ ok: true, await: 'human', participants: [{ memberKey: 'd' }] }, 'a').participants,
[{ memberKey: 'd' }],
)
assert.deepEqual(classify({ ok: true }, 'a').participants, [])
assert.deepEqual(classify({ ok: true, await: 'human' }, 'a').participants, [])
})
test('a FAILED envelope carries no participants at all', () => {
// A step that did not succeed did not observe anybody, and an action that
// reported attendance alongside a refusal is reporting something it cannot
// know. There is no `participants` on a failure classification to read.
assert.equal(classify({ ok: false, participants: [{ memberKey: 'd' }] }, 'a').participants, undefined)
})