|
|
|
|
@@ -1117,6 +1117,177 @@ Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a m
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### The six event tables — the engine's, game-agnostic (events phase 1)
|
|
|
|
|
|
|
|
|
|
Design of record: [`EVENTS.md`](EVENTS.md) §D. Nine core tables are specified there; **six land in
|
|
|
|
|
Phase 1** — 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, rather than as empty tables nothing reads.
|
|
|
|
|
|
|
|
|
|
Core owns the engine; a module owns the meaning. No column below carries a game noun: an action id,
|
|
|
|
|
a `scope`, a resource kind and a budget dimension are opaque strings core stores and never
|
|
|
|
|
interprets.
|
|
|
|
|
|
|
|
|
|
#### event_series — the arc
|
|
|
|
|
| col | type | notes |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| id | INT AUTO_INCREMENT PK | |
|
|
|
|
|
| name / slug | VARCHAR(160) NOT NULL, `UNIQUE(slug)` | |
|
|
|
|
|
| description | TEXT NULL | |
|
|
|
|
|
| ordering | INT NOT NULL DEFAULT 0 | where this series sits among the others. **Not** a position within it — that is `event_definitions.series_order`, which is the column an editor drags |
|
|
|
|
|
| created_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
|
|
|
|
| created_at / updated_at | DATETIME | |
|
|
|
|
|
|
|
|
|
|
Read-only through Phase 1: a definition may be pointed at a series, and creating or ordering one
|
|
|
|
|
arrives with the calendar.
|
|
|
|
|
|
|
|
|
|
#### event_definitions — the thing that is listed, scheduled and audited
|
|
|
|
|
| col | type | notes |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| id | INT AUTO_INCREMENT PK | |
|
|
|
|
|
| title | VARCHAR(200) NOT NULL | |
|
|
|
|
|
| slug | VARCHAR(200) NOT NULL, `UNIQUE` | derived from the title **once** and frozen, like a Team's: the public event page lives at it |
|
|
|
|
|
| summary | VARCHAR(500) NULL | |
|
|
|
|
|
| body | MEDIUMTEXT NULL | the storyline. Sanitized on write through `utils/sanitizeHtml.cleanBody`, exactly as a wiki page is |
|
|
|
|
|
| image_url | VARCHAR(500) NULL | |
|
|
|
|
|
| owner_module | VARCHAR(64) NULL | 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 is the ordinary case |
|
|
|
|
|
| state | ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft' | three states, not five. An admin publishes their own work, so there is nobody to submit it to |
|
|
|
|
|
| current_version_id | INT NULL | **no foreign key**, the one column in this group without one: `event_versions.definition_id` already points back here, and a second FK the other way makes the pair a chicken and an egg on insert |
|
|
|
|
|
| spec | JSON NOT NULL | **the working copy** — phases and their steps, as the author last saved it. Not in §D's column list; see below |
|
|
|
|
|
| series_id | INT NULL FK→event_series(id) ON DELETE SET NULL | |
|
|
|
|
|
| series_order | INT NOT NULL DEFAULT 0 | this definition's place within its arc |
|
|
|
|
|
| concurrency_key | VARCHAR(190) NULL | stored as the **template** (`invasion:{region}`), rendered from a run's own params at materialisation. A flat definition-id key would wrongly stop one definition running in two regions at once |
|
|
|
|
|
| grace_seconds | INT NOT NULL DEFAULT 900 | a schedule that passed this long ago while the process was down is `missed`, never a late silent start. Validated 60..86 400 |
|
|
|
|
|
| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | IANA, and it belongs to the **event**: 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. Validated against the platform's own tzdata via `Intl.DateTimeFormat` |
|
|
|
|
|
| created_by / updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
|
|
|
|
| created_at / updated_at | DATETIME | |
|
|
|
|
|
|
|
|
|
|
`INDEX(state, updated_at)` — the admin list's ordering and the public calendar's filter.
|
|
|
|
|
`INDEX(series_id, series_order)` — the arc.
|
|
|
|
|
|
|
|
|
|
**`spec` is Phase 1's one addition to §D's column list, and it is forced by the versioning rule.**
|
|
|
|
|
"Editing a draft is free; no version exists yet" means the working copy has to live 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 exactly what versioning exists to prevent. Publishing copies this
|
|
|
|
|
column into a version and leaves it standing as the next draft.
|
|
|
|
|
|
|
|
|
|
#### event_versions — the immutable snapshot a run pins
|
|
|
|
|
| col | type | notes |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| id | INT AUTO_INCREMENT PK | |
|
|
|
|
|
| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | |
|
|
|
|
|
| version | INT NOT NULL, `UNIQUE(definition_id, version)` | two publishes racing for version 4 is one 1062, not two rows called 4 |
|
|
|
|
|
| spec | JSON NOT NULL | phases, steps, schedule — the whole authored tree |
|
|
|
|
|
| published_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
|
|
|
|
|
| published_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
|
|
|
|
|
|
|
|
|
**Nothing updates a row here and nothing deletes one.** Editing a `ready` definition creates the
|
|
|
|
|
*next* version on publish; a live run keeps the version it pinned and is unaffected. That pin is what
|
|
|
|
|
makes a run reproducible and an audit answerable after the definition has moved on.
|
|
|
|
|
|
|
|
|
|
#### event_runs — one occurrence, in one scope
|
|
|
|
|
| col | type | notes |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| id | BIGINT AUTO_INCREMENT PK | |
|
|
|
|
|
| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | |
|
|
|
|
|
| version_id | INT NOT NULL FK→event_versions(id) | **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 |
|
|
|
|
|
| scope | VARCHAR(190) NOT NULL DEFAULT `''` | module-opaque; core never parses it. `''` and **not NULL**, because it is part of a UNIQUE key and multiple NULLs do not collide in MariaDB — a NULL scope would silently permit two runs of one occurrence |
|
|
|
|
|
| status | ENUM('scheduled','starting','running','paused','ending','completed','cancelled','failed','missed') NOT NULL DEFAULT 'scheduled' | `starting` and `ending` exist for the reason `sending` does in the outbox: they are what a claim sets. `missed` is terminal |
|
|
|
|
|
| health | ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok' | separate from `status`, because a run can be genuinely running *and* degraded — announcements landing, world writes parked — and one column cannot say both |
|
|
|
|
|
| cleanup_status | ENUM('not_required','pending','complete','incomplete') NOT NULL DEFAULT 'not_required' | also separate: a run reaches `completed` with `incomplete` cleanup rather than being held open, and stays on the admin screen until a human resolves it |
|
|
|
|
|
| current_phase | VARCHAR(64) NULL | |
|
|
|
|
|
| scheduled_for | DATETIME NOT NULL | **UTC**. The definition's zone is what an occurrence is computed *in*; what is stored is the instant |
|
|
|
|
|
| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | copied from the definition at materialisation |
|
|
|
|
|
| concurrency_key | VARCHAR(190) NULL | the definition's template, rendered against this run's params |
|
|
|
|
|
| params | JSON NULL | |
|
|
|
|
|
| rehearsal | TINYINT(1) NOT NULL DEFAULT 0 | dispatches for real; excluded from the public calendar and from participation history |
|
|
|
|
|
| started_at / ended_at | DATETIME NULL | |
|
|
|
|
|
| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | the lease. Written by the runner |
|
|
|
|
|
| started_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
|
|
|
|
| last_error | VARCHAR(500) NULL | |
|
|
|
|
|
| created_at / updated_at | DATETIME | |
|
|
|
|
|
|
|
|
|
|
`UNIQUE(definition_id, scope, scheduled_for)` — **and it, not the claim, is what makes "one run per
|
|
|
|
|
occurrence per scope" true.** 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. Materialisation is `INSERT IGNORE` against it, so asking twice for one
|
|
|
|
|
occurrence answers with the existing row rather than raising a duplicate-key error a caller has to
|
|
|
|
|
interpret.
|
|
|
|
|
|
|
|
|
|
`INDEX(status, scheduled_for)` the runner's scan · `INDEX(definition_id, scheduled_for)` the run list
|
|
|
|
|
· `INDEX(concurrency_key, status)` the overlap check.
|
|
|
|
|
|
|
|
|
|
#### event_run_steps — the work queue
|
|
|
|
|
| col | type | notes |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| id | BIGINT AUTO_INCREMENT PK | |
|
|
|
|
|
| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | |
|
|
|
|
|
| phase / seq | VARCHAR(64) NOT NULL / INT NOT NULL | |
|
|
|
|
|
| action_id | VARCHAR(96) NOT NULL | a declared action id. **No FK and no existence check**, for the reason `engagement_rules.trigger_id` has none: an action is declared in code, so a step naming one no module currently registers is *dormant*, never deleted |
|
|
|
|
|
| params | JSON NULL | validated against the action's declared params at save |
|
|
|
|
|
| action_version | INT NOT NULL DEFAULT 1 | what the step was AUTHORED against. A bump makes the editor warn rather than dispatch a mistyped parameter |
|
|
|
|
|
| status | ENUM('pending','running','done','failed','skipped','refused','cancelled') NOT NULL DEFAULT 'pending' | **`refused` is the cap breach and is deliberately not `failed`**: nothing is wrong with the system, an author asked for more than this deployment allows |
|
|
|
|
|
| due_at | DATETIME NULL | |
|
|
|
|
|
| attempts | INT NOT NULL DEFAULT 0 | |
|
|
|
|
|
| on_failure | VARCHAR(32) NOT NULL DEFAULT 'pause' | `skip` · `pause` · `abort_run`, defaulted from the action's risk class at save: `notify`/`inspect` → skip, `change` → pause, `irreversible` → abort_run |
|
|
|
|
|
| idempotency_key | CHAR(40) NOT NULL | `sha256(runId\|stepId)` truncated to 40 hex, the shape `shardEvents.dedupeKey` uses. **Minted once at materialisation and it does not vary by attempt** — a retry re-sends the same key so the game side can recognise the repeat |
|
|
|
|
|
| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | |
|
|
|
|
|
| last_error | VARCHAR(500) NULL | |
|
|
|
|
|
| started_at / finished_at | DATETIME NULL | |
|
|
|
|
|
| created_at / updated_at | DATETIME | |
|
|
|
|
|
|
|
|
|
|
`UNIQUE(run_id, phase, seq)` — materialisation is `INSERT IGNORE` against it, so a tick that overran
|
|
|
|
|
into the next one cannot double-materialise a phase. `INDEX(status, due_at)` the drain scan ·
|
|
|
|
|
`INDEX(run_id, phase, seq)` the run console.
|
|
|
|
|
|
|
|
|
|
#### event_run_log — "why didn't phase 3 start?" must be a query
|
|
|
|
|
| col | type | notes |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| id | BIGINT AUTO_INCREMENT PK | |
|
|
|
|
|
| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | |
|
|
|
|
|
| step_id | BIGINT NULL FK→event_run_steps(id) ON DELETE SET NULL | |
|
|
|
|
|
| kind | VARCHAR(48) NOT NULL | a **closed set enforced in `eventRunLog.db.js`, not an ENUM**: the set grows with almost every later phase, and an ENUM change is a table alter this project has no migration system for. Phase 1's five: `run.created`, `run.status`, `phase.entered`, `step.status`, `note` |
|
|
|
|
|
| phase | VARCHAR(64) NULL | |
|
|
|
|
|
| detail | JSON NULL | structured, and that is the whole point — `activity_log.detail` is TEXT and unqueryable |
|
|
|
|
|
| at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
|
|
|
|
|
|
|
|
|
|
`INDEX(run_id, at)` the console · `INDEX(at)` the retention sweep the runner phase adds.
|
|
|
|
|
|
|
|
|
|
**This table sits beside `activity_log`, not instead of it.** Both are written: the administrative
|
|
|
|
|
audit of *who published what* goes to the activity log, the diagnosis of *why a run did what it did*
|
|
|
|
|
goes here. They are different questions with different readers and different retention. The writer
|
|
|
|
|
**never throws** — a failure to record why something went wrong must not become a second failure on
|
|
|
|
|
top of the first.
|
|
|
|
|
|
|
|
|
|
**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 lands with the runner, and the index it needs is in the DDL from the
|
|
|
|
|
beginning.
|
|
|
|
|
|
|
|
|
|
#### The action registry — declared, never stored
|
|
|
|
|
|
|
|
|
|
Actions, budget dimensions and conditions are **registry entries, not tables** (§D "Not tables,
|
|
|
|
|
deliberately"): a module declares them at `register()`, like streams and audiences, and a stored one
|
|
|
|
|
would outlive the module that can perform it. `modules/registries.js` gained
|
|
|
|
|
`registerEventActions` in this phase, with core as its first registrant —
|
|
|
|
|
`config/coreEventActions.js` declares `core.announce`, `core.wait` and `core.cue`, so the seam is
|
|
|
|
|
exercised on every boot long before a module uses it.
|
|
|
|
|
|
|
|
|
|
The declaration is shape-checked at the call: the id grammar (its **own** namespace — an action names
|
|
|
|
|
a verb and a trigger names an event, so one id may legitimately be both), a required `risk` over the
|
|
|
|
|
closed four-value set, a required `reversible` over its own four, `revert()` required **iff and only
|
|
|
|
|
iff** `reversible: 'ledger'`, a bounded `budgetMs`, and a param list whose every entry needs a type
|
|
|
|
|
and an `example`. `perform`, `revert` and `cost` are stripped from everything the admin catalog
|
|
|
|
|
serves, exactly as an audience's `resolve` is: the browser's whole relationship with an action is
|
|
|
|
|
naming one by id.
|
|
|
|
|
|
|
|
|
|
`registerEventActions` is on the staging area and is reached **only** by `registerCore()`. `loader.js`
|
|
|
|
|
builds its own `api` facade for a module and has no method that delegates to it, so no module can
|
|
|
|
|
call it yet and `MODULE_API_VERSION` is untouched — the module contract, and the bump, are a later
|
|
|
|
|
phase's.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 4. API contract
|
|
|
|
|
|
|
|
|
|
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
|
|
|
|
|
@@ -1512,6 +1683,17 @@ file a route sits in — that is the property the route manifest freezes.
|
|
|
|
|
| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately |
|
|
|
|
|
| GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term |
|
|
|
|
|
| GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply |
|
|
|
|
|
| GET | `/events` | every definition with its state and current version. `?state=` filters to `draft`/`ready`/`archived` |
|
|
|
|
|
| GET | `/events/:id` | one definition **including its working spec** — the list serves a summary, this is the authored tree the editor renders |
|
|
|
|
|
| POST | `/events` | **admin, editor.** Create a draft. The slug is derived from the title once and frozen: the public event page lives at it, so a retitle must not break a posted link. A step naming an action no module registers is refused |
|
|
|
|
|
| PUT | `/events/:id` | **admin, editor.** Editing never touches a published version — a live run keeps the one it pinned. A step whose module has since been uninstalled is **kept and marked dormant**, not refused: the rule `engagement_rules` established for a dormant trigger, because an uninstall must not be destructive after the fact. `409` on an archived definition |
|
|
|
|
|
| GET | `/events/:id/versions` | the version history. Nothing edits a version; the row flagged `current` is what a new run pins |
|
|
|
|
|
| POST | `/events/:id/publish` | **admin only** ([`EVENTS.md`](EVENTS.md) §N2) — publishing commits a definition that a schedule will later start unattended, which is deliberately not the same gate as the live run controls. Snapshots the working spec into an immutable version. **Re-validates against the registries as they stand right now**, not from the save that wrote it: `409` naming the action when a step went dormant in between, `400` when no phase has any steps |
|
|
|
|
|
| DELETE | `/events/:id` | **admin only.** Archive — there is no hard delete at all, because a run pins a version and a run that could not be explained afterwards defeats the audit this system exists to provide. `409` while a run of it is still in flight |
|
|
|
|
|
| POST | `/events/:id/runs` | **admin only**, on the same reasoning as publish. Creates an occurrence. `INSERT IGNORE` against `UNIQUE(definition_id, scope, scheduled_for)`, so asking twice answers `200` with `created: false` and the existing row rather than creating a second. Optional `scope`, `scheduledFor`, `rehearsal`, `params` |
|
|
|
|
|
| GET | `/events/runs` · `/events/runs/:runId` · `/events/runs/:runId/log` | the run list, the run console (steps, their params and their idempotency keys, plus status counts) and the diagnostic log |
|
|
|
|
|
| GET | `/events/catalog` | the registered actions with their param schemas, risk classes and reversibility, plus the closed vocabularies the authoring form renders. **Served from the registries, not from a table** — a module that was uninstalled simply stops appearing |
|
|
|
|
|
| GET | `/events/series` | the arcs a definition may belong to. Read-only in this phase |
|
|
|
|
|
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
|
|
|
|
|
|
|
|
|
|
Every admin write logs to `activity_log`.
|
|
|
|
|
|