feat(events): schema, CRUD and the core action registry (Phase 1)
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user