feat(events): schema, CRUD and the core action registry (Phase 1)
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 13m24s

EVENTS_PLAN.md Phase 1. Six of the nine core tables — the ones that do not
depend on the module contract — plus definitions CRUD, publish, archive, and
the action registry with core as its first registrant.

**Nothing dispatches.** There is no runner until Phase 2, so a run row is
created and stays `scheduled`. That is this phase's correct answer and the
surface renders it verbatim rather than hiding it.

Schema (`db/schema.sql`, append-only):
  event_series, event_definitions, event_versions, event_runs,
  event_run_steps, event_run_log. The four that need a writer —
  event_action_settings, event_run_budget, event_run_resources,
  event_run_participants — arrive with the phases that give them one.

Registry (`modules/registries.js` + `config/coreEventActions.js`):
  registerEventActions staging and commit, with its own id namespace, the
  closed risk and reversibility sets, revert() required iff and only iff
  reversible: 'ledger', a bounded budgetMs and a param shape whose every
  entry needs a type and an example. perform/revert/cost are stripped from
  everything the catalog serves. Core declares core.announce, core.wait and
  core.cue through the same staging area a module will use.

  It is reachable ONLY by registerCore(): loader.js builds its own api facade
  and has no method that delegates here, so no module can call it and
  MODULE_API_VERSION is untouched. Phase 7 adds the facade and the bump.

Surface (13 routes under /api/v1/admin/events):
  Reads staff-wide; publish, archive and run creation admin-only from this
  phase per EVENTS.md §N2, even though the switchboard they will consult does
  not exist yet — a button that is admin-only later and open now is a gate
  nobody notices was missing. The live run controls and `verify` are absent
  rather than stubbed, because nothing is in flight yet.

Four things the build settled, all recorded in docs:
  - event_definitions gained a `spec` column. A draft's working copy cannot
    be an event_versions row: that table is immutable and a run pins one.
  - The spec validator must accept its own output. It added `actionVersion`
    and `dormant` and then refused them as unknown keys, which would have made
    the second save of any definition — and publish's re-validation —
    impossible. A test caught it; both are now accepted and recomputed.
  - A param's `example` is required, optional params included, matching
    registerEventTriggers. It is the authoring form's placeholder.
  - Two routes the §API-surface table did not name: GET /admin/events/:id and
    GET /admin/events/series.

Core's three perform() bodies answer { ok: false, retry: false } rather than
{ ok: true }: `ok: true` on an action that did nothing is a recorded world
change that did not occur, which is the exact mistake §F's failure default
exists to prevent.

`conditions.checkLiteral` is exported and reused for step-param type checking
— one switch over the six types, so "is this a datetime" has one answer.

Verified: 44 new tests, whole server suite, `npm run check:modules`, routes
manifest and swagger regenerated (the manifest diff is +13 routes, zero moved).

Docs: RunicGateway/docs#209

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 23:29:07 -05:00
parent 6331b36c45
commit 8e03497eb3
23 changed files with 4714 additions and 1 deletions

View File

@@ -2083,3 +2083,269 @@ CREATE TABLE IF NOT EXISTS engagement_suppressions (
INDEX idx_engsup_created (created_at),
INDEX idx_engsup_reason (reason, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── The Event System (EVENTS.md §D — Phase 1) ──────────────────────────────
-- Six of the nine core tables land here: the ones that do not depend on the
-- module contract. `event_action_settings`, `event_run_budget`,
-- `event_run_resources` and `event_run_participants` arrive with the phases that
-- give them a writer (P6, P8, P10) rather than as empty tables nothing reads.
--
-- Core tables, so no module prefix, and no game vocabulary anywhere below: an
-- action id, a scope, a resource kind and a budget dimension are all opaque
-- strings core stores and never interprets (§C).
-- The arc. Definitions optionally belong to one, and the series is what carries
-- continuity across them — "Royal Spy Mission -> Risky Partner -> Message From
-- the Void" is a thing the tooling this replaces cannot express at all.
--
-- The table lands in Phase 1 because `event_definitions.series_id` points at it;
-- the routes that create and order one are Phase 4's, where the calendar makes
-- an arc visible.
CREATE TABLE IF NOT EXISTS event_series (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(160) NOT NULL,
slug VARCHAR(160) NOT NULL,
description TEXT NULL,
-- Where this series sits among the others on the calendar. Not a position
-- WITHIN the series: a definition's place in its arc is `event_definitions`'
-- own `series_order` below, because that is the column an editor drags.
ordering INT NOT NULL DEFAULT 0,
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evser_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_evser_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The thing that is listed, searched, scheduled and audited.
--
-- **Three states, not five** (§E). There is no `submitted` and no `approved`: an
-- admin publishes their own work, so there is nobody to submit it to, and a
-- review state nobody uses is a state every query has to remember anyway.
--
-- `owner_module` is NULLable and is the module that SHIPPED this definition as
-- content, not the module whose actions its steps call — a definition may call
-- three modules' verbs and belong to none of them. NULL means an operator
-- authored it here, which is the ordinary case.
--
-- `concurrency_key` is stored as the TEMPLATE, not as the rendered value
-- (`invasion:{region}`), because it is rendered from a run's own params at
-- materialisation (§E). A flat definition-id key would wrongly stop one
-- definition running in two regions at once.
--
-- `current_version_id` carries NO foreign key, deliberately, and it is the one
-- column in this group without one: `event_versions.definition_id` already
-- points back here, and a second FK in the other direction makes the pair a
-- chicken and an egg on insert.
CREATE TABLE IF NOT EXISTS event_definitions (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
slug VARCHAR(200) NOT NULL,
summary VARCHAR(500) NULL,
-- The storyline. Sanitized HTML, the same treatment a wiki page gets.
body MEDIUMTEXT NULL,
image_url VARCHAR(500) NULL,
owner_module VARCHAR(64) NULL,
state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft',
current_version_id INT NULL,
-- The WORKING COPY of the spec - phases and their steps - as the author last
-- saved it. §D's column list does not name it because §D describes what a
-- PUBLISHED event is made of, and a version row is where a spec ends up. But
-- "editing a draft is free; no version exists yet" (EVENTS.md "Versioning")
-- has to mean the draft lives somewhere, and it cannot be an `event_versions`
-- row: that table is immutable and a run pins one, so a mutable unpublished
-- row in it would be the exact thing versioning exists to prevent. Publishing
-- copies this column into a version and leaves it here as the next draft.
spec JSON NOT NULL,
series_id INT NULL,
series_order INT NOT NULL DEFAULT 0,
concurrency_key VARCHAR(190) NULL,
-- The grace window (§E). A schedule that passed this many seconds ago while the
-- process was down is `missed`, never a late silent start.
grace_seconds INT NOT NULL DEFAULT 900,
-- IANA, and it belongs to the EVENT rather than to the viewer: every listing
-- this replaces is written in the shard's local zone, and a recurrence computed
-- in UTC puts a Friday-8pm event at 7pm for half the year.
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
created_by INT NULL,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evdef_series FOREIGN KEY (series_id) REFERENCES event_series(id) ON DELETE SET NULL,
CONSTRAINT fk_evdef_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_evdef_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_evdef_slug (slug),
-- The admin list's default ordering, and the public calendar's filter.
INDEX idx_evdef_state (state, updated_at),
INDEX idx_evdef_series (series_id, series_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- An immutable snapshot of a whole definition spec: phases, steps, schedule,
-- conditions, announcements. A run pins one, and that pin is the entire reason
-- this table exists — it is what makes a run reproducible, and an audit
-- answerable, after the definition has been edited underneath it.
--
-- Nothing updates a row here. Editing a `ready` definition creates the NEXT
-- version on publish; a live run keeps the version it pinned and is unaffected
-- (EVENTS.md "Versioning, and editing a live event").
CREATE TABLE IF NOT EXISTS event_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
definition_id INT NOT NULL,
version INT NOT NULL,
spec JSON NOT NULL,
published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_by INT NULL,
CONSTRAINT fk_evver_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE,
CONSTRAINT fk_evver_user FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL,
-- Two publishes racing for version 4 is one 1062, not two rows called 4.
UNIQUE KEY uq_evver_def_version (definition_id, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One occurrence of one definition, in one scope.
--
-- The UNIQUE key below — not the claim — is what makes "one run per occurrence
-- per scope" true (§E). The claim decides WHO advances an occurrence; this index
-- is what stops two of them existing. `scope` is inside the key so a worldwide
-- event fans out to many servers without colliding with itself, and it is
-- module-opaque: core stores the string and never parses it.
--
-- `scheduled_for` is UTC. The definition's IANA zone is what the occurrence was
-- COMPUTED in (Phase 4); what is stored is the instant.
--
-- `health` is a separate column from `status` because a run can be genuinely
-- running and degraded at once — announcements landing, world writes parked —
-- and one column cannot say both. `cleanup_status` is separate for the mirror
-- reason: a run reaches `completed` with `cleanup_status = 'incomplete'` rather
-- than being held open, and stays on the admin screen until a human resolves it.
--
-- `version_id`'s foreign key has no ON DELETE clause, so it RESTRICTs: a run
-- whose pinned spec had been deleted could not be explained afterwards, which is
-- the one thing this table is for.
CREATE TABLE IF NOT EXISTS event_runs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
definition_id INT NOT NULL,
version_id INT NOT NULL,
-- Module-opaque, and '' rather than NULL for the single-scope case: it is part
-- of a UNIQUE key, and multiple NULLs do not collide in MariaDB, so a NULL
-- scope would silently permit two runs of one occurrence.
scope VARCHAR(190) NOT NULL DEFAULT '',
status ENUM('scheduled','starting','running','paused','ending',
'completed','cancelled','failed','missed')
NOT NULL DEFAULT 'scheduled',
health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok',
cleanup_status ENUM('not_required','pending','complete','incomplete')
NOT NULL DEFAULT 'not_required',
current_phase VARCHAR(64) NULL,
scheduled_for DATETIME NOT NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
concurrency_key VARCHAR(190) NULL, -- rendered from this run's params
params JSON NULL,
-- A rehearsal dispatches for real but is excluded from the public calendar and
-- from participation history. Declared here in Phase 1 so the column exists
-- before anything can create a run without it.
rehearsal TINYINT(1) NOT NULL DEFAULT 0,
started_at DATETIME NULL,
ended_at DATETIME NULL,
claimed_by VARCHAR(64) NULL,
claim_expires_at DATETIME NULL,
started_by INT NULL,
last_error VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evrun_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE,
CONSTRAINT fk_evrun_version FOREIGN KEY (version_id) REFERENCES event_versions(id),
CONSTRAINT fk_evrun_user FOREIGN KEY (started_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_evrun_occurrence (definition_id, scope, scheduled_for),
-- The runner's materialise/advance scan: due runs by status.
INDEX idx_evrun_due (status, scheduled_for),
-- The admin run list, newest first, and the per-definition history.
INDEX idx_evrun_def (definition_id, scheduled_for),
-- Phase 2's overlap check. NULL keys are skipped by the index, which is right:
-- a definition with no key never contends.
INDEX idx_evrun_concurrency (concurrency_key, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The work queue: one action invocation, claimed with the outbox's
-- compare-and-set. This is the shape `engagement_outbox` already proved, and
-- retries, timeouts, duplicate execution and resumption are all properties of
-- this row rather than of a scheduler's memory.
--
-- `idempotency_key` is minted ONCE at materialisation and does not vary by
-- attempt (§E) — a retry re-sends the same key so the game side can recognise
-- the repeat. It is generated by core rather than by the module because core is
-- what guarantees its stability.
--
-- `action_version` records what the step was AUTHORED against. A module that
-- bumps its action makes the step render a warning in the editor rather than
-- dispatch a mistyped parameter.
--
-- `refused` is in the status set and is deliberately not `failed`: a cap breach
-- means nothing is wrong with the system, and an author asked for more than this
-- deployment allows.
CREATE TABLE IF NOT EXISTS event_run_steps (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
run_id BIGINT NOT NULL,
phase VARCHAR(64) NOT NULL,
seq INT NOT NULL,
action_id VARCHAR(96) NOT NULL,
params JSON NULL,
action_version INT NOT NULL DEFAULT 1,
status ENUM('pending','running','done','failed','skipped','refused','cancelled')
NOT NULL DEFAULT 'pending',
due_at DATETIME NULL,
attempts INT NOT NULL DEFAULT 0,
-- Defaulted from the action's risk class at materialisation (§L): retry->skip
-- for notify, retry->pause for change, retry->abort_run for irreversible.
on_failure VARCHAR(32) NOT NULL DEFAULT 'skip',
idempotency_key CHAR(40) NOT NULL,
claimed_by VARCHAR(64) NULL,
claim_expires_at DATETIME NULL,
last_error VARCHAR(500) NULL,
started_at DATETIME NULL,
finished_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evstep_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
-- The drain scan, exactly as written: due, pending, oldest first.
INDEX idx_evstep_due (status, due_at),
-- The run console: every step of one run in authored order.
INDEX idx_evstep_run (run_id, phase, seq),
-- Materialisation is INSERT IGNORE against this, so a tick that overruns into
-- the next one cannot double-materialise a phase.
UNIQUE KEY uq_evstep_slot (run_id, phase, seq)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- "Why didn't phase 3 start?" must be a query.
--
-- `activity_log.detail` is TEXT and unqueryable, which is the whole reason this
-- table exists rather than the audit log being reused: an operator diagnosing a
-- stalled phase needs to filter by kind and read structured detail, and an
-- administrative audit of WHO published WHAT is a different question with a
-- different retention. Both are written — the audit to `activity_log`, the
-- diagnosis here.
--
-- `kind` is a closed set enforced in `eventRunLog.db.js` rather than an ENUM,
-- because the set grows with almost every later phase and an ENUM change is a
-- table alter this project has no migration system for.
--
-- The log is high-cardinality and grows per event, so it needs a retention sweep
-- from the start — `engagementRetentionPrune` is the pattern, and the rule that
-- work learned is that only TERMINAL rows are eligible. The sweep itself lands
-- with the runner in Phase 2; the index it needs is here from the beginning.
CREATE TABLE IF NOT EXISTS event_run_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
run_id BIGINT NOT NULL,
step_id BIGINT NULL,
kind VARCHAR(48) NOT NULL,
phase VARCHAR(64) NULL,
detail JSON NULL,
at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_evlog_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
CONSTRAINT fk_evlog_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
-- The run console reads this whole index and nothing else.
INDEX idx_evlog_run (run_id, at),
-- What the Phase 2 retention sweep queries. Without it the sweep is a table
-- scan of every line this deployment has ever logged.
INDEX idx_evlog_at (at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;