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:
@@ -124,6 +124,20 @@ const triggers = new Map()
|
||||
// trigger of the same name would be a collision between two unrelated things.
|
||||
const audiences = new Map()
|
||||
|
||||
// action id → { owner, id, label, description, risk, reversible, version,
|
||||
// budgetMs, params, cost, perform, revert } (EVENTS.md §F, Phase 1).
|
||||
//
|
||||
// **Its own id space**, like `audiences` above and for the same kind of reason:
|
||||
// an action names a VERB and a trigger names an EVENT, so `uo.champ.start` as
|
||||
// the thing a module can be asked to do and `uo.champ.start` as the thing that
|
||||
// happened are two unrelated declarations that must not collide with — or
|
||||
// silently satisfy — each other. Nothing cross-checks this map against the
|
||||
// stream/trigger namespace, and nothing should.
|
||||
//
|
||||
// A Map, and read by id on the dispatch path exactly as `triggers` is; insertion
|
||||
// order is what the admin catalog renders in.
|
||||
const eventActions = new Map()
|
||||
|
||||
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
|
||||
// decision 7). What a module ships as CONTENT rather than as contract: the
|
||||
// bodies its triggers render through, and the rules an operator switches on.
|
||||
@@ -163,6 +177,10 @@ const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/
|
||||
// Audiences are their own id space (see the `audiences` Map), so they get their
|
||||
// own constant even though the grammar is the same one.
|
||||
const AUDIENCE_ID = EVENT_ID
|
||||
// Likewise event actions (EVENTS.md §F, "Actions and budgets are their own id
|
||||
// spaces"). One grammar, three namespaces — the constant is what makes the
|
||||
// namespace visible at every use site.
|
||||
const ACTION_ID = EVENT_ID
|
||||
|
||||
// A module's claim must carry its id. Core's ids are its own namespace, and the
|
||||
// grandfathered names are the ones that predate all of this.
|
||||
@@ -367,6 +385,33 @@ async function resolveAudience(id, params = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event actions (EVENTS.md §F) ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every declaration WITHOUT its callables — what the admin catalog serves.
|
||||
*
|
||||
* `perform`, `revert` and `cost` are stripped for the same reason `resolve` is
|
||||
* stripped from an audience and `handler` from a slash command: this is the
|
||||
* object that leaves the process, and the browser's whole relationship with an
|
||||
* action is naming one by id. §F's "a module registers actions server-side and
|
||||
* adds no routes for them" is only true if the functions never ride out.
|
||||
*/
|
||||
const allEventActions = () =>
|
||||
[...eventActions.values()].map(({ perform, revert, cost, ...rest }) => rest)
|
||||
|
||||
/** One declaration, callables included. The runner's lookup (Phase 2). */
|
||||
const eventAction = (id) => eventActions.get(id) || null
|
||||
|
||||
/**
|
||||
* Does anyone register this id right now?
|
||||
*
|
||||
* The authoring path's question, and it is deliberately not `eventAction(id) !==
|
||||
* null` at every call site: a step naming an action whose module is uninstalled
|
||||
* is DORMANT, not an error (§F), and the difference between "never existed" and
|
||||
* "not installed today" is a distinction only the caller can draw.
|
||||
*/
|
||||
const isEventAction = (id) => eventActions.has(id)
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
//
|
||||
// Split from the collision checks below on the same line PR 3 drew through
|
||||
@@ -745,6 +790,170 @@ function checkAudienceShape(entry) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event action shape (EVENTS.md §F) ──────────────────────────────────────
|
||||
|
||||
// Four values, closed, core-owned (§N6). Deliberately NOT "world-read" and
|
||||
// "world-write", which are game words a chess ladder has no use for — and
|
||||
// deliberately not extensible by a module, because the class is what core
|
||||
// derives a step's `on_failure` from (§L) and a module that could invent
|
||||
// `harmless` would be choosing its own retry policy.
|
||||
const ACTION_RISKS = ['notify', 'inspect', 'change', 'irreversible']
|
||||
|
||||
// What core must know in order to clean up after a run (§L). `none` is gone once
|
||||
// done; `self` undoes itself; `ledger` needs a `revert` over the rows core
|
||||
// recorded; `override` is a lease, whose baseline core restores.
|
||||
const ACTION_REVERSIBLE = ['none', 'self', 'ledger', 'override']
|
||||
|
||||
// The same six types a trigger variable uses. One vocabulary over both, because
|
||||
// the authoring form that renders an action param and the template editor that
|
||||
// renders a trigger variable are the same widget over the same six types, and a
|
||||
// second list is a list that drifts.
|
||||
const ACTION_PARAM_TYPES = VARIABLE_TYPES
|
||||
|
||||
// The param name grammar, shared with trigger variables for the same reason: a
|
||||
// param ends up as a key in a JSON object an operator reads.
|
||||
const PARAM_NAME = VARIABLE_NAME
|
||||
|
||||
// The default per-invocation deadline. Ten seconds is §F's own figure and it is
|
||||
// the number the sidecar's own request timeout is set near — long enough for a
|
||||
// round trip through a module, a sidecar and a game tick, short enough that a
|
||||
// wedged action does not hold a step's claim past its lease.
|
||||
const DEFAULT_BUDGET_MS = 10_000
|
||||
// An hour. Not "unlimited by another name": the bound exists so that a typo in a
|
||||
// declaration is a slow action rather than a step that never times out at all,
|
||||
// and Phase 2's lease has to be longer than this to mean anything.
|
||||
const MAX_BUDGET_MS = 3_600_000
|
||||
|
||||
function checkActionParam(actionId, entry, seen) {
|
||||
const { name, type, required, example, description, source } = entry || {}
|
||||
const where = `registerEventActions: ${actionId}`
|
||||
if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`)
|
||||
if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`)
|
||||
seen.add(name)
|
||||
if (!ACTION_PARAM_TYPES.includes(type)) {
|
||||
throw new Error(`${where}: param "${name}" has unsupported type "${type}"`)
|
||||
}
|
||||
// REQUIRED, on every param including the optional ones, and it is the same
|
||||
// argument `checkTriggerVariable` makes: without it the authoring form has no
|
||||
// placeholder and the operator is typing into a blank box, which is exactly
|
||||
// how an unattended world write comes to be scheduled with a typo in it. It is
|
||||
// one word at declaration time and unreconstructable afterwards.
|
||||
if (example === undefined || example === null || example === '') {
|
||||
throw new Error(`${where}: param "${name}" needs an example (it is the authoring placeholder)`)
|
||||
}
|
||||
// A `source` names a module-served option endpoint, so the field is a dropdown
|
||||
// of real values rather than a text box (§F "Param option sources"). It is
|
||||
// checked as an id here and resolved nowhere yet — the endpoint that answers it
|
||||
// is Phase 7's, and a `source` naming nothing degrades the field to free text
|
||||
// with a warning rather than blocking the form.
|
||||
if (source !== undefined && !ACTION_ID.test(source || '')) {
|
||||
throw new Error(`${where}: param "${name}" has a bad option source "${source}"`)
|
||||
}
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
required: Boolean(required),
|
||||
example,
|
||||
source: source === undefined ? null : source,
|
||||
description: description || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert }])`.
|
||||
*
|
||||
* A typed verb core may ask a registrant to carry out. Everything decidable from
|
||||
* the argument alone is decided here, at the call; the collision — is this id
|
||||
* already someone's action? — waits for `apply()`.
|
||||
*
|
||||
* The copy at the end is explicit rather than a spread, like every other shape
|
||||
* check in this file: this object is served to the admin catalog and is what a
|
||||
* step's params are validated against, so anything not named here is not part of
|
||||
* the contract and must not ride along.
|
||||
*
|
||||
* **Nothing here executes and nothing here may touch the database.** Registration
|
||||
* runs under `routeManifest.js` and `swagger.js` against a dead pool
|
||||
* (MODULE_API.md §2.2), and core's own registration is subject to the same rule
|
||||
* as a module's.
|
||||
*/
|
||||
function checkEventActionShape(entry) {
|
||||
const a = entry || {}
|
||||
if (!ACTION_ID.test(a.id || '')) {
|
||||
throw new Error(`registerEventActions: bad action id "${a.id}"`)
|
||||
}
|
||||
if (!a.label) throw new Error(`registerEventActions: action "${a.id}" has no label`)
|
||||
|
||||
// Both required with no default, for the reason a trigger's ceiling is: there
|
||||
// is no safe value to guess. Defaulting `risk` to `notify` would give a world
|
||||
// write the retry policy of a broadcast, and defaulting `reversible` to `none`
|
||||
// would tell the cleanup generator there is nothing to undo.
|
||||
if (!ACTION_RISKS.includes(a.risk)) {
|
||||
throw new Error(
|
||||
`registerEventActions: ${a.id} needs a risk class, one of ${ACTION_RISKS.join(', ')}`,
|
||||
)
|
||||
}
|
||||
if (!ACTION_REVERSIBLE.includes(a.reversible)) {
|
||||
throw new Error(
|
||||
`registerEventActions: ${a.id} needs a reversible class, one of ${ACTION_REVERSIBLE.join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof a.perform !== 'function') {
|
||||
throw new Error(`registerEventActions: ${a.id} has no perform()`)
|
||||
}
|
||||
// §F: `revert` is required iff `reversible === 'ledger'`. Checked here rather
|
||||
// than discovered at teardown, because the moment it matters is the moment a
|
||||
// run has already created something and the answer "there is no undo" is the
|
||||
// one answer cleanup cannot act on.
|
||||
if (a.reversible === 'ledger' && typeof a.revert !== 'function') {
|
||||
throw new Error(`registerEventActions: ${a.id} is reversible: 'ledger' but has no revert()`)
|
||||
}
|
||||
// The mirror check, and it is not pedantry: a `revert` on a `reversible:
|
||||
// 'none'` action is a module author who believes their action can be undone
|
||||
// and a cleanup generator that will never call it. Silence there is a promise
|
||||
// core does not keep.
|
||||
if (a.revert !== undefined && a.reversible !== 'ledger') {
|
||||
throw new Error(
|
||||
`registerEventActions: ${a.id} declares revert() but is reversible: '${a.reversible}'`,
|
||||
)
|
||||
}
|
||||
if (a.cost !== undefined && typeof a.cost !== 'function') {
|
||||
throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`)
|
||||
}
|
||||
|
||||
const version = a.version === undefined ? 1 : a.version
|
||||
if (!Number.isInteger(version) || version < 1) {
|
||||
throw new Error(`registerEventActions: ${a.id} has a bad version "${a.version}"`)
|
||||
}
|
||||
|
||||
const budgetMs = a.budgetMs === undefined ? DEFAULT_BUDGET_MS : a.budgetMs
|
||||
if (!Number.isInteger(budgetMs) || budgetMs <= 0 || budgetMs > MAX_BUDGET_MS) {
|
||||
throw new Error(
|
||||
`registerEventActions: ${a.id} budgetMs must be 1..${MAX_BUDGET_MS} ms, got "${a.budgetMs}"`,
|
||||
)
|
||||
}
|
||||
|
||||
if (a.params !== undefined && !Array.isArray(a.params)) {
|
||||
throw new Error(`registerEventActions: ${a.id} params must be an array`)
|
||||
}
|
||||
const seen = new Set()
|
||||
const params = (a.params || []).map((p) => checkActionParam(a.id, p, seen))
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
description: a.description || '',
|
||||
risk: a.risk,
|
||||
reversible: a.reversible,
|
||||
version,
|
||||
budgetMs,
|
||||
params,
|
||||
cost: a.cost || null,
|
||||
perform: a.perform,
|
||||
revert: a.revert || null,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Engagement seeds (Phase 11b, decision 7) ───────────────────────────────
|
||||
//
|
||||
// **Two mechanisms, and the asymmetry between them is the whole design.**
|
||||
@@ -983,6 +1192,7 @@ function stage(owner) {
|
||||
slashCommands: [],
|
||||
triggers: [],
|
||||
audiences: [],
|
||||
eventActions: [],
|
||||
engagementSeeds: [],
|
||||
}
|
||||
return {
|
||||
@@ -1015,6 +1225,16 @@ function stage(owner) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array')
|
||||
for (const e of entries) staged.audiences.push(checkAudienceShape(e))
|
||||
},
|
||||
// EVENTS.md §F, Phase 1. Present on the staging area from this phase and
|
||||
// reached ONLY by `registerCore()` below — `loader.js` builds its own `api`
|
||||
// facade and has no method that delegates here, so a module cannot call this
|
||||
// yet. Phase 7 adds that facade and bumps MODULE_API to 1.10.0; until then
|
||||
// the seam is exercised on every boot by core's own three actions and by
|
||||
// nothing else, which is the point of registering them through it.
|
||||
registerEventActions(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerEventActions: expected an array')
|
||||
for (const e of entries) staged.eventActions.push(checkEventActionShape(e))
|
||||
},
|
||||
registerEngagementSeeds(entry) {
|
||||
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
|
||||
},
|
||||
@@ -1040,6 +1260,7 @@ function apply({
|
||||
slashCommands: newSlashCommands = [],
|
||||
triggers: newTriggers = [],
|
||||
audiences: newAudiences = [],
|
||||
eventActions: newEventActions = [],
|
||||
engagementSeeds: newSeeds = [],
|
||||
}) {
|
||||
// ── validate ──
|
||||
@@ -1096,6 +1317,23 @@ function apply({
|
||||
seenAudiences.add(a.id)
|
||||
}
|
||||
|
||||
// Actions, against their OWN map and nothing else. No cross-facet check with
|
||||
// streams or triggers: an action id and a trigger id are different namespaces
|
||||
// (§F), so `uo.champ.start` may legitimately be both a verb and an event, and
|
||||
// reading a collision there would forbid the most natural pair of names a
|
||||
// module will ever want. No legacy allowlist either — nothing predates this,
|
||||
// so the prefix rule has no exceptions and should never grow one.
|
||||
const seenActions = new Set()
|
||||
for (const a of newEventActions) {
|
||||
const held = eventActions.get(a.id)
|
||||
if (held) throw new Error(`event action "${a.id}" is already registered by "${held.owner}"`)
|
||||
if (seenActions.has(a.id)) throw new Error(`event action "${a.id}" registered twice`)
|
||||
if (!namespaced(owner, a.id, {})) {
|
||||
throw new Error(`event action "${a.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenActions.add(a.id)
|
||||
}
|
||||
|
||||
const seenLegs = new Set()
|
||||
for (const l of newLegs) {
|
||||
const held = legs.get(l.leg)
|
||||
@@ -1160,6 +1398,7 @@ function apply({
|
||||
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
|
||||
for (const t of newTriggers) triggers.set(t.id, { owner, ...t })
|
||||
for (const a of newAudiences) audiences.set(a.id, { owner, ...a })
|
||||
for (const a of newEventActions) eventActions.set(a.id, { owner, ...a })
|
||||
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
|
||||
}
|
||||
|
||||
@@ -1182,6 +1421,7 @@ function registerCore() {
|
||||
/* eslint-disable global-require */
|
||||
const coreStreams = require('../config/coreStreams')
|
||||
const coreTriggers = require('../config/coreTriggers')
|
||||
const coreEventActions = require('../config/coreEventActions')
|
||||
const discordLeg = require('../utils/discordAnnounce')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -1192,6 +1432,11 @@ function registerCore() {
|
||||
// its five stream ids — the same-owner upgrade the one-namespace rule above is
|
||||
// written for — so this batch exercises the cross-facet check on every boot.
|
||||
api.registerEventTriggers(coreTriggers.TRIGGERS)
|
||||
// The event contract (EVENTS.md §F, Phase 1). Core registers `core.announce`,
|
||||
// `core.wait` and `core.cue` through the SAME staging area Phase 7 will hand a
|
||||
// module, so the registry is exercised on every boot long before a module uses
|
||||
// it — the argument registerCore() has made since the module system's Phase 3.
|
||||
api.registerEventActions(coreEventActions.ACTIONS)
|
||||
|
||||
// The three lines that used to follow — the shard stream catalog, the town
|
||||
// crier leg and the `admin.users.detail` filling — were shard CONTENT held
|
||||
@@ -1205,6 +1450,7 @@ function registerCore() {
|
||||
log.info('core registrations complete', {
|
||||
streams: streams.length,
|
||||
eventTriggers: triggers.size,
|
||||
eventActions: eventActions.size,
|
||||
announceLegs: legs.size,
|
||||
extensions: [...slots.keys()].filter(slotFilledBy),
|
||||
})
|
||||
@@ -1236,6 +1482,7 @@ function _reset() {
|
||||
slashCommands.clear()
|
||||
triggers.clear()
|
||||
audiences.clear()
|
||||
eventActions.clear()
|
||||
engagementSeeds.clear()
|
||||
coreRegistered = false
|
||||
}
|
||||
@@ -1264,11 +1511,18 @@ module.exports = {
|
||||
allAudiences,
|
||||
audience,
|
||||
resolveAudience,
|
||||
allEventActions,
|
||||
eventAction,
|
||||
isEventAction,
|
||||
allEngagementSeeds,
|
||||
engagementSeedsFor,
|
||||
SEEDABLE_CHANNELS,
|
||||
VARIABLE_TYPES,
|
||||
TRIGGER_KINDS,
|
||||
ACTION_RISKS,
|
||||
ACTION_REVERSIBLE,
|
||||
ACTION_PARAM_TYPES,
|
||||
DEFAULT_BUDGET_MS,
|
||||
stage,
|
||||
apply,
|
||||
registerCore,
|
||||
|
||||
Reference in New Issue
Block a user