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:
317
server/src/events/spec.js
Normal file
317
server/src/events/spec.js
Normal file
@@ -0,0 +1,317 @@
|
||||
// ── The event spec, and the one place it is validated ──────────────────────
|
||||
//
|
||||
// EVENTS.md §C ("phases and actions are configuration inside a version snapshot,
|
||||
// not tables") and §D. A spec is the authored tree a definition carries and a
|
||||
// version freezes: phases, and the steps inside them. It is stored as JSON in
|
||||
// `event_definitions`' working copy and in `event_versions.spec`, and this file
|
||||
// is the only thing that decides whether one is well formed.
|
||||
//
|
||||
// **Every check here is a boundary, not a convenience.** The authoring UI (Phase
|
||||
// 3, then Phase 13) will re-implement some of them for the sake of a good
|
||||
// inline error, and that second copy is expected to drift — so this one is the
|
||||
// one that decides. A spec arriving by any other route (a restore, a fixture, a
|
||||
// module shipping a definition as content) gets the same answer.
|
||||
//
|
||||
// **What Phase 1 knows, and what it deliberately refuses.** Two top-level keys
|
||||
// exist today: `schedule` and `phases`. `schedule` accepts only `{ kind:
|
||||
// 'manual' }`, because Phase 4 is what computes an occurrence from a recurrence
|
||||
// in an IANA zone and a spec that could name `weekly` before then would be a
|
||||
// schedule nothing honours. Unknown top-level keys are REFUSED rather than
|
||||
// preserved: a spec that silently carries `announcements` today is a spec whose
|
||||
// author believes announcements work, and the later phase that gives the key
|
||||
// meaning would inherit a corpus of unvalidated ones. The refusal list is the
|
||||
// changelog — Phase 4 adds the recurrence shapes, Phase 5 adds a phase's
|
||||
// `advance`, Phase 10 adds `announcements`.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const { checkLiteral } = require('../engagement/conditions')
|
||||
|
||||
// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the
|
||||
// run console groups by, and it is what an operator reads in "phase 3 has not
|
||||
// started". Same grammar as a template key's segment.
|
||||
const PHASE_KEY = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/
|
||||
const MAX_PHASE_KEY = 64
|
||||
|
||||
// Bounds, not guesses. They exist so that a paste of the wrong JSON is a refusal
|
||||
// with a number in it rather than a run that materialises fifty thousand step
|
||||
// rows — the same argument `MAX_SENDS_PER_HOUR` makes on the engagement side.
|
||||
const MAX_PHASES = 40
|
||||
const MAX_STEPS_PER_PHASE = 100
|
||||
const MAX_STEPS = 500
|
||||
|
||||
// The schedule shapes this phase understands. Phase 4 replaces this list with
|
||||
// the four closed shapes of §E — `once`, `weekly`, `monthly`, `manual` — and
|
||||
// their timezone arithmetic. It is a list of one rather than an implicit default
|
||||
// so that the widening is a diff on this line.
|
||||
const SCHEDULE_KINDS = ['manual']
|
||||
|
||||
// What a step does when its attempts are exhausted (§L). The disposition only —
|
||||
// retry is not one of the values, it is what happens BEFORE one of them. Each
|
||||
// risk class has a default, which is the whole reason `risk` is required at
|
||||
// registration: a `change` action that fell back to `skip` would leave a run
|
||||
// advancing over a half-changed world.
|
||||
const ON_FAILURE = ['skip', 'pause', 'abort_run']
|
||||
const ON_FAILURE_BY_RISK = {
|
||||
notify: 'skip',
|
||||
inspect: 'skip',
|
||||
change: 'pause',
|
||||
irreversible: 'abort_run',
|
||||
}
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/** The default disposition for an action whose risk class core knows. */
|
||||
const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause'
|
||||
|
||||
/**
|
||||
* Check one authored param object against an action's declared params.
|
||||
*
|
||||
* Returns `{ params, errors }`. Unknown params are an ERROR rather than a silent
|
||||
* drop: an author who typed `creatures` where the action declares `creature` has
|
||||
* written a step that would dispatch with the count missing, and dropping the key
|
||||
* makes that look like it saved cleanly.
|
||||
*/
|
||||
function checkParams(declaration, raw, path) {
|
||||
const errors = []
|
||||
const params = {}
|
||||
const given = isPlainObject(raw) ? raw : {}
|
||||
|
||||
if (raw !== undefined && raw !== null && !isPlainObject(raw)) {
|
||||
return { params, errors: [`${path}.params: expected an object`] }
|
||||
}
|
||||
|
||||
const declared = new Map((declaration.params || []).map((p) => [p.name, p]))
|
||||
for (const name of Object.keys(given)) {
|
||||
if (!declared.has(name)) {
|
||||
errors.push(`${path}.params: "${name}" is not a param of ${declaration.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of declaration.params || []) {
|
||||
const value = given[p.name]
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
|
||||
continue
|
||||
}
|
||||
const checked = checkLiteral(p.type, value)
|
||||
if (checked.error) {
|
||||
errors.push(`${path}.params: "${p.name}" ${checked.error}`)
|
||||
continue
|
||||
}
|
||||
params[p.name] = checked.value
|
||||
}
|
||||
|
||||
return { params, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalise a whole spec.
|
||||
*
|
||||
* `{ ok: true, spec }` with a new normalised tree, or `{ ok: false, errors }`
|
||||
* listing EVERY problem rather than the first — the posture `conditions.validate`
|
||||
* takes, and for the same reason: an author fixing one step at a time is an
|
||||
* author making six round trips through a form.
|
||||
*
|
||||
* `knownActionIds` widens what may be named beyond what is registered right now,
|
||||
* and it is how a definition survives its module being uninstalled. The rule,
|
||||
* lifted verbatim from `engagementRules.model`'s treatment of a dormant trigger:
|
||||
* **a step already in the saved spec may keep an unregistered action; a new step
|
||||
* may not add one.** Refusing to save the whole definition would make an
|
||||
* uninstall destructive after the fact, and silently dropping the step would
|
||||
* delete authored work to make a form submit. A kept step is marked
|
||||
* `dormant: true` and its params pass through unvalidated, because the only
|
||||
* thing that could validate them left with the module.
|
||||
*/
|
||||
function validate(raw, { knownActionIds = [] } = {}) {
|
||||
const errors = []
|
||||
const known = new Set(knownActionIds)
|
||||
|
||||
if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] }
|
||||
|
||||
const allowed = new Set(['schedule', 'phases'])
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!allowed.has(key)) {
|
||||
errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── schedule ──
|
||||
const rawSchedule = raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
|
||||
let schedule = { kind: 'manual' }
|
||||
if (!isPlainObject(rawSchedule)) {
|
||||
errors.push('spec.schedule: expected an object')
|
||||
} else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) {
|
||||
errors.push(
|
||||
`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')} (recurrence arrives in Phase 4)`,
|
||||
)
|
||||
} else {
|
||||
const extra = Object.keys(rawSchedule).filter((k) => k !== 'kind')
|
||||
if (extra.length) errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')}`)
|
||||
schedule = { kind: rawSchedule.kind }
|
||||
}
|
||||
|
||||
// ── phases ──
|
||||
const rawPhases = raw.phases
|
||||
const phases = []
|
||||
if (!Array.isArray(rawPhases) || rawPhases.length === 0) {
|
||||
errors.push('spec.phases: expected a non-empty array')
|
||||
return { ok: false, errors }
|
||||
}
|
||||
if (rawPhases.length > MAX_PHASES) {
|
||||
errors.push(`spec.phases: at most ${MAX_PHASES} phases`)
|
||||
return { ok: false, errors }
|
||||
}
|
||||
|
||||
const seenKeys = new Set()
|
||||
let totalSteps = 0
|
||||
|
||||
rawPhases.forEach((rawPhase, pi) => {
|
||||
const path = `spec.phases[${pi}]`
|
||||
if (!isPlainObject(rawPhase)) {
|
||||
errors.push(`${path}: expected an object`)
|
||||
return
|
||||
}
|
||||
const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps'].includes(k))
|
||||
if (extra.length) {
|
||||
errors.push(`${path}: unknown key(s) ${extra.join(', ')} (a phase gains "advance" in Phase 5)`)
|
||||
}
|
||||
|
||||
const key = rawPhase.key
|
||||
if (typeof key !== 'string' || !PHASE_KEY.test(key) || key.length > MAX_PHASE_KEY) {
|
||||
errors.push(`${path}.key: bad phase key "${key}"`)
|
||||
} else if (seenKeys.has(key)) {
|
||||
// Not cosmetic: `event_run_steps` is UNIQUE on (run_id, phase, seq), so two
|
||||
// phases sharing a key would silently collapse into one at materialisation
|
||||
// and half the authored steps would never exist.
|
||||
errors.push(`${path}.key: "${key}" is used by more than one phase`)
|
||||
} else {
|
||||
seenKeys.add(key)
|
||||
}
|
||||
|
||||
if (!rawPhase.label) errors.push(`${path}.label: a phase needs a label`)
|
||||
|
||||
const rawSteps = rawPhase.steps
|
||||
if (!Array.isArray(rawSteps)) {
|
||||
errors.push(`${path}.steps: expected an array`)
|
||||
return
|
||||
}
|
||||
if (rawSteps.length > MAX_STEPS_PER_PHASE) {
|
||||
errors.push(`${path}.steps: at most ${MAX_STEPS_PER_PHASE} steps in one phase`)
|
||||
return
|
||||
}
|
||||
totalSteps += rawSteps.length
|
||||
|
||||
const steps = []
|
||||
rawSteps.forEach((rawStep, si) => {
|
||||
const spath = `${path}.steps[${si}]`
|
||||
if (!isPlainObject(rawStep)) {
|
||||
errors.push(`${spath}: expected an object`)
|
||||
return
|
||||
}
|
||||
// `actionVersion` and `dormant` are in this list because **validate must
|
||||
// accept its own output**. A saved spec is re-validated on every later
|
||||
// save and again at publish, so a normalised field that the validator
|
||||
// itself added and then refused would make the second save of any
|
||||
// definition impossible. They are accepted and then RECOMPUTED below
|
||||
// rather than trusted: the version comes from the declaration, and
|
||||
// dormancy from whether anyone registers the action right now.
|
||||
const stepExtra = Object.keys(rawStep).filter(
|
||||
(k) => !['actionId', 'params', 'onFailure', 'label', 'actionVersion', 'dormant'].includes(k),
|
||||
)
|
||||
if (stepExtra.length) errors.push(`${spath}: unknown key(s) ${stepExtra.join(', ')}`)
|
||||
|
||||
const actionId = rawStep.actionId
|
||||
const declaration = typeof actionId === 'string' ? registries.eventAction(actionId) : null
|
||||
|
||||
if (typeof actionId !== 'string' || !actionId) {
|
||||
errors.push(`${spath}.actionId: a step needs an action`)
|
||||
return
|
||||
}
|
||||
if (!declaration && !known.has(actionId)) {
|
||||
errors.push(`${spath}.actionId: no module registers "${actionId}"`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!declaration) {
|
||||
// Dormant: kept verbatim, params untouched, and flagged so the editor and
|
||||
// the run console can both say WHY rather than showing an empty step.
|
||||
steps.push({
|
||||
actionId,
|
||||
label: rawStep.label || actionId,
|
||||
params: isPlainObject(rawStep.params) ? rawStep.params : {},
|
||||
actionVersion: Number.isInteger(rawStep.actionVersion) ? rawStep.actionVersion : 1,
|
||||
onFailure: ON_FAILURE.includes(rawStep.onFailure) ? rawStep.onFailure : 'pause',
|
||||
dormant: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { params, errors: paramErrors } = checkParams(declaration, rawStep.params, spath)
|
||||
errors.push(...paramErrors)
|
||||
|
||||
if (rawStep.onFailure !== undefined && !ON_FAILURE.includes(rawStep.onFailure)) {
|
||||
errors.push(`${spath}.onFailure: must be one of ${ON_FAILURE.join(', ')}`)
|
||||
}
|
||||
|
||||
steps.push({
|
||||
actionId,
|
||||
label: rawStep.label || declaration.label,
|
||||
params,
|
||||
// Captured at SAVE time, from the declaration this step was authored
|
||||
// against (§F). It is what lets a later bump render a warning in the
|
||||
// editor instead of dispatching a mistyped parameter.
|
||||
actionVersion: declaration.version,
|
||||
onFailure: ON_FAILURE.includes(rawStep.onFailure)
|
||||
? rawStep.onFailure
|
||||
: defaultOnFailure(declaration.risk),
|
||||
dormant: false,
|
||||
})
|
||||
})
|
||||
|
||||
phases.push({ key, label: rawPhase.label, steps })
|
||||
})
|
||||
|
||||
if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`)
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
return { ok: true, spec: { schedule, phases } }
|
||||
}
|
||||
|
||||
/** Every action id a spec names, dormant ones included. */
|
||||
const actionIdsIn = (spec) =>
|
||||
(spec?.phases || []).flatMap((p) => (p.steps || []).map((s) => s.actionId)).filter(Boolean)
|
||||
|
||||
/**
|
||||
* A spec is publishable when nothing in it is dormant.
|
||||
*
|
||||
* Separate from `validate` on purpose: a dormant step must not stop an author
|
||||
* SAVING (that is what makes an uninstall non-destructive), and it must stop
|
||||
* them PUBLISHING, because publishing is what makes a version a thing runs are
|
||||
* pinned to and a run cannot dispatch a verb nobody registers.
|
||||
*/
|
||||
function publishable(spec) {
|
||||
const dormant = (spec?.phases || [])
|
||||
.flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId))
|
||||
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
|
||||
}
|
||||
|
||||
/** An empty, valid spec — what a newly created draft carries. */
|
||||
const emptySpec = () => ({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [] }],
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
publishable,
|
||||
actionIdsIn,
|
||||
emptySpec,
|
||||
defaultOnFailure,
|
||||
PHASE_KEY,
|
||||
SCHEDULE_KINDS,
|
||||
ON_FAILURE,
|
||||
ON_FAILURE_BY_RISK,
|
||||
MAX_PHASES,
|
||||
MAX_STEPS_PER_PHASE,
|
||||
MAX_STEPS,
|
||||
}
|
||||
Reference in New Issue
Block a user