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:
@@ -2597,3 +2597,83 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
|
||||
-- start is watching, and that human is the review the gate exists to require.
|
||||
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL;
|
||||
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL;
|
||||
|
||||
-- ── Integrations: participants, results and the run's announcements
|
||||
-- (EVENTS.md §D/§J — Phase 10) ─────────────────────────────────────────────
|
||||
|
||||
-- Who took part, and how well. The eleventh and last of §D's core tables.
|
||||
--
|
||||
-- **Core writes this table and never sources it.** A `member_key` is
|
||||
-- module-opaque, exactly like a resource's `ref`: core cannot map "Darrow of
|
||||
-- Britain" onto a user row and must not try, because the mapping is one game's
|
||||
-- (`shard_links`, for module-uo) and would be compiled into core the moment it
|
||||
-- guessed. A module that knows both halves supplies both — `memberKey` always,
|
||||
-- `userId` when its own link table has one — and core stores what it is told.
|
||||
--
|
||||
-- `SET NULL` rather than `CASCADE`, matching `engagement_sends`: a record of what
|
||||
-- happened at an event has to survive the deletion of an account that attended
|
||||
-- it, or the results of last year's invasion silently rewrite themselves.
|
||||
--
|
||||
-- **`rank` is NULL until results are published** and is computed then, by
|
||||
-- `core.results.publish`, over `score DESC`. It is a stored column rather than a
|
||||
-- window function in the read because a published result is a fact about a
|
||||
-- moment: a participant added afterwards (a late correction, a module's second
|
||||
-- collect step) must not silently renumber a table people have already read.
|
||||
CREATE TABLE IF NOT EXISTS event_run_participants (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
-- Module-opaque and NOT NULL: it is the identity the module knows, and the
|
||||
-- half of the unique key that makes a repeated collect idempotent. A run whose
|
||||
-- module cannot name its participants has no rows here at all.
|
||||
member_key VARCHAR(190) NOT NULL,
|
||||
user_id INT NULL,
|
||||
-- Signed, because a game may score downward as readily as upward, and DECIMAL
|
||||
-- rather than a float so two equal scores compare equal and a rank is stable.
|
||||
score DECIMAL(18,4) NOT NULL DEFAULT 0,
|
||||
rank_at INT NULL,
|
||||
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- Module-opaque. Whatever the module wants results to be able to display
|
||||
-- beside a name -- a class, a city, a kill count -- with no core vocabulary in
|
||||
-- it and nothing core ever reads.
|
||||
meta JSON NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evpart_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evpart_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- One row per participant per run. What makes a module reporting the same set
|
||||
-- twice -- a retried collect step, a second attempt after a timeout -- an
|
||||
-- upsert rather than a duplicated leaderboard.
|
||||
UNIQUE KEY uq_evpart_member (run_id, member_key),
|
||||
-- The results table: one run, best first.
|
||||
INDEX idx_evpart_score (run_id, score),
|
||||
-- Profile history (`GET /player/events/history`, Phase 14), newest first.
|
||||
INDEX idx_evpart_user (user_id, joined_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- When the results table was published, and by which run of the publish action.
|
||||
--
|
||||
-- A stamp rather than a status: a run either has published results or it has
|
||||
-- not, and the two questions a surface asks -- "may I show this table" and "when
|
||||
-- was it settled" -- are the same column. `core.results.publish` is idempotent
|
||||
-- against it (a re-run re-ranks and re-stamps), which is what makes it safe as an
|
||||
-- ordinary retried step.
|
||||
ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NULL;
|
||||
|
||||
-- The run an announce job belongs to, when it belongs to one.
|
||||
--
|
||||
-- **Nullable, and every existing row keeps NULL**: the news pipeline's jobs are
|
||||
-- not an event's, and nothing about how they are enqueued, retried or rolled up
|
||||
-- changes. What this buys is that `core.announce.post` may enqueue a SECOND job
|
||||
-- for a post that has already been announced -- the common case, since the post
|
||||
-- an event announces is very often the news post that announced it -- without
|
||||
-- either colliding with the first or overwriting `posts.announce_job_id`, which
|
||||
-- is the back-pointer the post admin panel's retry button reads.
|
||||
--
|
||||
-- **No foreign key, exactly like `posts.announce_job_id` beside it.** An announce
|
||||
-- job that went out is a delivery record and must outlive whatever asked for it,
|
||||
-- and `ADD CONSTRAINT ... FOREIGN KEY` has no `IF NOT EXISTS` in MariaDB -- so a
|
||||
-- constraint here would be the one statement in this file that cannot replay.
|
||||
-- The column is read only to answer "which run announced this", and a run id
|
||||
-- that no longer resolves answers that honestly.
|
||||
ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL;
|
||||
ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id);
|
||||
|
||||
Reference in New Issue
Block a user