// ── 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 }