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:
196
server/test/eventActionRegistry.test.js
Normal file
196
server/test/eventActionRegistry.test.js
Normal file
@@ -0,0 +1,196 @@
|
||||
// ── The event action registry (EVENTS.md §F, Phase 1) ──────────────────────
|
||||
//
|
||||
// Phase 1's acceptance criteria for the registry half, one test apiece:
|
||||
//
|
||||
// • core's three actions register on every boot and appear in the catalog
|
||||
// • the catalog never carries a callable — no `perform`, `revert` or `cost`
|
||||
// • a module registering an un-namespaced action fails, with the holder named
|
||||
// • the closed sets are closed: an invented risk or reversibility is refused
|
||||
// • `reversible: 'ledger'` without a `revert()` is refused AT REGISTRATION,
|
||||
// not discovered at teardown when something has already been created
|
||||
// • an action id and a trigger id are DIFFERENT namespaces, so one id may
|
||||
// legitimately be both — the property the audience registry established and
|
||||
// this one inherits
|
||||
//
|
||||
// Point the DB at a closed port BEFORE requiring anything: registries.js reaches
|
||||
// utils/discordAnnounce, which reaches the pool at require time.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const coreEventActions = require('../src/config/coreEventActions')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
beforeEach(() => registries._reset())
|
||||
afterEach(() => registries._reset())
|
||||
|
||||
const ok = (over = {}) => ({
|
||||
id: 'demo.thing.do',
|
||||
label: 'Do the thing',
|
||||
risk: 'change',
|
||||
reversible: 'none',
|
||||
perform: async () => ({ ok: true }),
|
||||
...over,
|
||||
})
|
||||
|
||||
const register = (owner, entries) => {
|
||||
const api = registries.stage(owner)
|
||||
api.registerEventActions(entries)
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
test('core registers its three actions on every boot', () => {
|
||||
registries.registerCore()
|
||||
const ids = registries.allEventActions().map((a) => a.id)
|
||||
assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue'])
|
||||
assert.equal(ids.length, coreEventActions.ACTIONS.length)
|
||||
})
|
||||
|
||||
test('the catalog carries no callable', () => {
|
||||
registries.registerCore()
|
||||
for (const action of registries.allEventActions()) {
|
||||
assert.equal(action.perform, undefined, `${action.id} leaked perform`)
|
||||
assert.equal(action.revert, undefined, `${action.id} leaked revert`)
|
||||
assert.equal(action.cost, undefined, `${action.id} leaked cost`)
|
||||
}
|
||||
// …and the runner's own lookup still has it, which is the half that makes the
|
||||
// stripping a boundary rather than a deletion.
|
||||
assert.equal(typeof registries.eventAction('core.wait').perform, 'function')
|
||||
})
|
||||
|
||||
test('core placeholders refuse rather than claiming success', async () => {
|
||||
// Phase 1 declares; Phase 2 dispatches. The placeholder's answer matters
|
||||
// because `ok: true` on an action that did nothing is a recorded world change
|
||||
// that did not occur — the one wrong answer a stub can give.
|
||||
registries.registerCore()
|
||||
for (const id of ['core.announce', 'core.wait', 'core.cue']) {
|
||||
const answer = await registries.eventAction(id).perform({})
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.retry, false)
|
||||
assert.match(answer.error, new RegExp(id.replace('.', '\\.')))
|
||||
}
|
||||
})
|
||||
|
||||
test('an action must be namespaced to its owner, and the holder is named', () => {
|
||||
assert.throws(() => register('demo', [ok({ id: 'other.thing.do' })]), /not namespaced "demo\."/)
|
||||
|
||||
register('demo', [ok()])
|
||||
assert.throws(
|
||||
() => register('rival', [ok({ id: 'demo.thing.do' })]),
|
||||
/already registered by "demo"/,
|
||||
)
|
||||
})
|
||||
|
||||
test('the same id twice in one batch is refused', () => {
|
||||
assert.throws(() => register('demo', [ok(), ok()]), /registered twice/)
|
||||
})
|
||||
|
||||
test('risk and reversibility are closed sets with no default', () => {
|
||||
assert.throws(() => register('demo', [ok({ risk: undefined })]), /needs a risk class/)
|
||||
assert.throws(() => register('demo', [ok({ risk: 'world-write' })]), /needs a risk class/)
|
||||
assert.throws(
|
||||
() => register('demo', [ok({ reversible: undefined })]),
|
||||
/needs a reversible class/,
|
||||
)
|
||||
assert.throws(() => register('demo', [ok({ reversible: 'maybe' })]), /needs a reversible class/)
|
||||
})
|
||||
|
||||
test("reversible: 'ledger' without revert() is refused at registration", () => {
|
||||
assert.throws(
|
||||
() => register('demo', [ok({ reversible: 'ledger' })]),
|
||||
/is reversible: 'ledger' but has no revert\(\)/,
|
||||
)
|
||||
// And the mirror: a revert() nothing will ever call is a promise core does not
|
||||
// keep, so it is refused just as loudly.
|
||||
assert.throws(
|
||||
() => register('demo', [ok({ reversible: 'none', revert: async () => ({ ok: true }) })]),
|
||||
/declares revert\(\) but is reversible: 'none'/,
|
||||
)
|
||||
register('demo', [ok({ reversible: 'ledger', revert: async () => ({ ok: true }) })])
|
||||
assert.equal(typeof registries.eventAction('demo.thing.do').revert, 'function')
|
||||
})
|
||||
|
||||
test('perform() is required and cost must be a function', () => {
|
||||
assert.throws(() => register('demo', [ok({ perform: undefined })]), /has no perform\(\)/)
|
||||
assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/)
|
||||
})
|
||||
|
||||
test('every param needs a type and an example', () => {
|
||||
const withParams = (params) => ok({ params })
|
||||
assert.throws(() => register('demo', [withParams([{ name: 'x' }])]), /unsupported type/)
|
||||
assert.throws(
|
||||
() => register('demo', [withParams([{ name: 'x', type: 'int' }])]),
|
||||
/needs an example/,
|
||||
)
|
||||
assert.throws(
|
||||
() => register('demo', [withParams([{ name: '9bad', type: 'int', example: 1 }])]),
|
||||
/bad param name/,
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
register('demo', [
|
||||
withParams([
|
||||
{ name: 'x', type: 'int', example: 1 },
|
||||
{ name: 'x', type: 'int', example: 2 },
|
||||
]),
|
||||
]),
|
||||
/declared twice/,
|
||||
)
|
||||
register('demo', [withParams([{ name: 'x', type: 'int', example: 12, source: 'demo.options.x' }])])
|
||||
const [param] = registries.eventAction('demo.thing.do').params
|
||||
assert.deepEqual(param, {
|
||||
name: 'x',
|
||||
type: 'int',
|
||||
required: false,
|
||||
example: 12,
|
||||
source: 'demo.options.x',
|
||||
description: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('budgetMs defaults, and is bounded', () => {
|
||||
register('demo', [ok()])
|
||||
assert.equal(registries.eventAction('demo.thing.do').budgetMs, registries.DEFAULT_BUDGET_MS)
|
||||
registries._reset()
|
||||
assert.throws(() => register('demo', [ok({ budgetMs: 0 })]), /budgetMs must be/)
|
||||
assert.throws(() => register('demo', [ok({ budgetMs: 3_600_001 })]), /budgetMs must be/)
|
||||
})
|
||||
|
||||
test('actions and triggers are different namespaces, so one id may be both', () => {
|
||||
// The property §F states and the audience registry established first. A verb
|
||||
// called `demo.raid.start` and an event called `demo.raid.start` are two
|
||||
// unrelated declarations, and forbidding the pair would forbid the most
|
||||
// natural names a module will ever want.
|
||||
const api = registries.stage('demo')
|
||||
api.registerEventTriggers([
|
||||
{ id: 'demo.raid.start', label: 'A raid started', ceiling: 'everyone' },
|
||||
])
|
||||
api.registerEventActions([ok({ id: 'demo.raid.start', label: 'Start a raid' })])
|
||||
registries.apply(api.staged)
|
||||
|
||||
assert.equal(registries.eventTrigger('demo.raid.start').label, 'A raid started')
|
||||
assert.equal(registries.eventAction('demo.raid.start').label, 'Start a raid')
|
||||
})
|
||||
|
||||
test('a whole batch is refused or taken, never half', () => {
|
||||
assert.throws(
|
||||
() => register('demo', [ok(), ok({ id: 'demo.other.do', risk: 'nope' })]),
|
||||
/needs a risk class/,
|
||||
)
|
||||
// The shape check throws at the CALL, before anything is staged, so nothing
|
||||
// from the batch is visible.
|
||||
assert.equal(registries.eventAction('demo.thing.do'), null)
|
||||
})
|
||||
|
||||
test('_reset() hands the process back', () => {
|
||||
registries.registerCore()
|
||||
assert.equal(registries.allEventActions().length, 3)
|
||||
registries._reset()
|
||||
assert.equal(registries.allEventActions().length, 0)
|
||||
assert.equal(registries.isEventAction('core.wait'), false)
|
||||
})
|
||||
Reference in New Issue
Block a user