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:
201
server/src/router/v1/admin/events.router.js
Normal file
201
server/src/router/v1/admin/events.router.js
Normal file
@@ -0,0 +1,201 @@
|
||||
// Admin · Events — definitions, versions, the action catalog and run reads
|
||||
// (EVENTS.md § API surface, Phase 1).
|
||||
//
|
||||
// Mounted at /api/v1/admin/events by admin/index.js, which has already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Every route below re-gates to the tier
|
||||
// EVENTS.md § API surface names for it.
|
||||
//
|
||||
// **The gates are the real ones from this phase, not placeholders.** §N2 decided
|
||||
// that publish and start are `admin` ONLY — a moderator keeps live control of a
|
||||
// run already in flight and nothing more — and the switchboard those gates will
|
||||
// eventually consult (`event_action_settings`, Phase 6) does not exist yet. They
|
||||
// are here anyway, because a button that is admin-only later and open now is a
|
||||
// gate nobody notices was missing.
|
||||
//
|
||||
// Reads are staff-wide. `verify` (admin, editor) is Phase 6's, and the live run
|
||||
// controls (admin, moderator) are Phase 3's — neither is stubbed here, because
|
||||
// nothing is in flight until Phase 2 builds the runner.
|
||||
//
|
||||
// **Literal paths are declared before `/:id`**, so `/catalog`, `/series` and
|
||||
// `/runs` are never read as an event id.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const controller = require('./events.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
|
||||
const eventsRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
const adminOrEditor = requireRole('admin', 'editor')
|
||||
|
||||
// ── The catalog and the vocabularies, served from the registries ───────────
|
||||
|
||||
eventsRouter.get(
|
||||
'/catalog',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility'
|
||||
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The registered actions and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.catalog,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/series',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'List the event series a definition may belong to'
|
||||
// #swagger.description = 'A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "array", items: { type: "object", properties: { id: { type: "integer" }, name: { type: "string" }, slug: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } } } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.listSeries,
|
||||
)
|
||||
|
||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Declared ahead of /:id so the literal path is never read as a definition id.
|
||||
|
||||
eventsRouter.get(
|
||||
'/runs',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'List event runs across every definition, newest occurrence first'
|
||||
// #swagger.description = 'A run is one occurrence of one definition in one scope. Until the runner ships, every row here sits at `scheduled` — that is correct for this phase rather than a stalled run.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['definitionId'] = { in: 'query', description: 'Only runs of this definition', required: false, schema: { type: 'integer' } }
|
||||
// #swagger.parameters['status'] = { in: 'query', description: 'Only runs in this status', required: false, schema: { type: 'string' } }
|
||||
// #swagger.parameters['limit'] = { in: 'query', description: 'How many rows, 1..500 (default 100)', required: false, schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'The runs', content: { "application/json": { schema: { type: "object", properties: { runs: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.listRuns,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/runs/:runId',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
|
||||
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The run, its steps and the status counts', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.getRun,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/runs/:runId/log',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'The diagnostic log for one run'
|
||||
// #swagger.description = 'Structured and queryable, unlike activity_log.detail: this is what answers "why did not phase 3 start?" without reading server logs. The audit of who published what is written separately to the activity log.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['limit'] = { in: 'query', description: 'How many lines, 1..2000 (default 500)', required: false, schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'The log, newest first, and the closed set of line kinds', content: { "application/json": { schema: { type: "object", properties: { log: { type: "array", items: { type: "object", additionalProperties: true } }, kinds: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.getRunLog,
|
||||
)
|
||||
|
||||
// ── Definitions ───────────────────────────────────────────────────────────
|
||||
|
||||
eventsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'List every event definition with its state and current version'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['state'] = { in: 'query', description: 'Only definitions in this state: draft, ready or archived', required: false, schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'The definitions', content: { "application/json": { schema: { type: "object", properties: { events: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.list,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Create a draft event definition'
|
||||
// #swagger.description = 'Creates a draft. The slug is derived from the title once and frozen afterwards, because the public event page lives at it. The spec defaults to one empty phase; steps are validated against the action catalog, and an unknown action id is refused.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { title: { type: "string" }, summary: { type: "string", nullable: true }, body: { type: "string", nullable: true }, imageUrl: { type: "string", nullable: true }, seriesId: { type: "integer", nullable: true }, seriesOrder: { type: "integer" }, concurrencyKey: { type: "string", nullable: true }, graceSeconds: { type: "integer" }, timezone: { type: "string" }, spec: { type: "object", additionalProperties: true } }, required: ["title"] } } } } */
|
||||
/* #swagger.responses[201] = { description: 'The created draft', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation failed; every problem is listed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.create,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'One event definition, including its working spec'
|
||||
// #swagger.description = 'The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.get,
|
||||
)
|
||||
|
||||
eventsRouter.put(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Edit a definition and its working spec'
|
||||
// #swagger.description = 'Editing is free and never touches a published version: a live run keeps the version it pinned. A step naming an action whose module has since been uninstalled is KEPT and marked dormant rather than refused, so an uninstall is never destructive after the fact — but a dormant step blocks publish. An archived definition cannot be edited.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { title: { type: "string" }, summary: { type: "string", nullable: true }, body: { type: "string", nullable: true }, imageUrl: { type: "string", nullable: true }, seriesId: { type: "integer", nullable: true }, seriesOrder: { type: "integer" }, concurrencyKey: { type: "string", nullable: true }, graceSeconds: { type: "integer" }, timezone: { type: "string" }, spec: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The saved definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation failed; every problem is listed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
adminOrEditor,
|
||||
controller.update,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/:id/versions',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'The version history of one definition'
|
||||
// #swagger.description = 'Versions are immutable and nothing edits one. The row flagged `current` is what a new run pins.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The versions, newest first', content: { "application/json": { schema: { type: "object", properties: { versions: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.listVersions,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/:id/publish',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Snapshot the working spec into an immutable version and mark the definition ready'
|
||||
// #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The definition, now ready, and the version that was cut', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The spec is invalid, or no phase has any steps', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'A step names an action no module registers, or the definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.publish,
|
||||
)
|
||||
|
||||
eventsRouter.delete(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Archive a definition — never a hard delete'
|
||||
// #swagger.description = 'Archiving keeps the definition history without it ever running again. Refused while a run of it is still in flight: cancel the run first. A hard delete is not offered 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.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The archived definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'A run of this definition is still in flight', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.archive,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/:id/runs',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Create an occurrence of a published definition'
|
||||
// #swagger.description = 'Admin only, on the same reasoning as publish: starting commits the deployment to a run. Materialised with INSERT IGNORE against UNIQUE (definition_id, scope, scheduled_for), so asking twice for one occurrence answers with the existing row and `created: false` rather than creating a second. Until the runner ships the row stays `scheduled` and nothing dispatches — its steps and their idempotency keys are inspectable in the meantime.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { scope: { type: "string", description: "Module-opaque. Core stores it verbatim and never parses it." }, scheduledFor: { type: "string", description: "UTC instant; defaults to now" }, rehearsal: { type: "boolean" }, params: { type: "object", additionalProperties: true, description: "Rendered into the definition concurrency_key template" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'The occurrence was created', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, created: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The occurrence already existed and is returned unchanged', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, created: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The definition is not ready, or has no published version', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.startRun,
|
||||
)
|
||||
|
||||
module.exports = eventsRouter
|
||||
Reference in New Issue
Block a user