Files
website/server/test/eventsAdmin.test.js
wtclaude 9bc0bf5a3d
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m28s
PR Checks / client-build (pull_request) Successful in 8m47s
feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where:
<conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.

`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.

One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.

A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-02 22:11:20 -05:00

678 lines
28 KiB
JavaScript

// ── The events admin surface (EVENTS.md § API surface, Phase 1) ────────────
//
// `eventSpec.test.js` covers the spec validator and `eventActionRegistry.test.js`
// the registry; re-asserting either here would be a second copy of a test rather
// than a second test. What is genuinely new is what the SURFACE decides:
//
// • **publish snapshots.** It cuts an immutable version, points the definition
// at it, and a later edit does not touch the version a run would pin.
// • **publish re-validates against the registries as they stand now**, not
// against the save that wrote the spec. A module uninstalled in between must
// block the publish, because the alternative is a run that fails at dispatch
// with the world half-changed.
// • **an empty event does not publish.** It would run cleanly and do nothing,
// which reads as a broken run rather than an empty one.
// • **the slug is frozen after create**, because the public event page lives
// at it and a retitle must not break a posted link.
// • **archiving is refused while a run is in flight**, and there is no hard
// delete at all.
// • **creating an occurrence twice creates ONE run.** The unique index is what
// makes that true, so the second call answers `created: false` with the
// existing row rather than erroring.
// • **a created run stays `scheduled`.** There is no runner until Phase 2, and
// that has to be visible as the correct state rather than as a stall.
//
// The `.db` layer is stubbed in-memory and the real models and controllers run
// against it, the shape `engagementAdmin.test.js` uses.
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 ctrl = require('../src/router/v1/admin/events.controller')
const definitionsDb = require('../src/model/events/eventDefinitions.db')
const versionsDb = require('../src/model/events/eventVersions.db')
const runsDb = require('../src/model/events/eventRuns.db')
const stepsDb = require('../src/model/events/eventRunSteps.db')
const logDb = require('../src/model/events/eventRunLog.db')
const seriesDb = require('../src/model/events/eventSeries.db')
const gatesDb = require('../src/model/events/eventPhaseGates.db')
const activity = require('../src/model/activity/activity.model')
const db = require('../src/utils/db')
after(() => db.close())
// ── In-memory stand-ins ────────────────────────────────────────────────────
let store
const originals = {}
for (const [name, mod] of [
['definitionsDb', definitionsDb],
['versionsDb', versionsDb],
['runsDb', runsDb],
['stepsDb', stepsDb],
['logDb', logDb],
['seriesDb', seriesDb],
['gatesDb', gatesDb],
['activity', activity],
]) {
originals[name] = { mod, fns: { ...mod } }
}
const restoreOriginals = () => {
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
}
// The occurrence key, as a string, so the stub can enforce the UNIQUE index the
// real table enforces. Reproducing it is the point of several tests below: the
// index — not the claim — is what makes "one run per occurrence per scope" true.
const occurrenceKey = (definitionId, scope, when) =>
`${definitionId}|${scope || ''}|${new Date(when).toISOString()}`
function installStubs() {
store = {
definitions: new Map(),
versions: new Map(),
runs: new Map(),
steps: new Map(),
log: [],
series: new Map(),
gates: [],
occurrences: new Set(),
nextDefinition: 1,
nextVersion: 1,
nextRun: 1,
nextStep: 1,
}
const shape = (d) => ({
...d,
series_name: store.series.get(d.series_id)?.name ?? null,
series_slug: store.series.get(d.series_id)?.slug ?? null,
current_version: store.versions.get(d.current_version_id)?.version ?? null,
})
definitionsDb.list = async ({ state = null } = {}) =>
[...store.definitions.values()].filter((d) => !state || d.state === state).map(shape)
definitionsDb.getById = async (id) =>
store.definitions.has(id) ? shape(store.definitions.get(id)) : undefined
definitionsDb.getBySlug = async (slug) =>
[...store.definitions.values()].filter((d) => d.slug === slug).map(shape)[0]
definitionsDb.slugTaken = async (slug, exceptId = null) =>
[...store.definitions.values()].some((d) => d.slug === slug && d.id !== exceptId)
definitionsDb.insert = async (d) => {
const id = store.nextDefinition++
store.definitions.set(id, {
id,
state: 'draft',
current_version_id: null,
created_at: new Date(),
updated_at: new Date(),
...d,
})
return id
}
definitionsDb.update = async (id, d) => {
const existing = store.definitions.get(id)
if (existing) Object.assign(existing, d, { updated_at: new Date() })
}
definitionsDb.markReady = async (id, versionId, userId) => {
const existing = store.definitions.get(id)
if (existing) {
Object.assign(existing, { state: 'ready', current_version_id: versionId, updated_by: userId })
}
}
definitionsDb.archive = async (id, userId) => {
const existing = store.definitions.get(id)
if (existing) Object.assign(existing, { state: 'archived', updated_by: userId })
}
versionsDb.listForDefinition = async (definitionId) =>
[...store.versions.values()]
.filter((v) => v.definition_id === definitionId)
.sort((a, b) => b.version - a.version)
versionsDb.getById = async (id) => store.versions.get(id) || undefined
versionsDb.nextVersion = async (definitionId) =>
[...store.versions.values()].filter((v) => v.definition_id === definitionId).length + 1
versionsDb.insert = async (definitionId, version, spec, userId) => {
const id = store.nextVersion++
// Deep-copied on the way in, because the whole point of a version is that a
// later edit of the working spec cannot reach it. A stub that stored the
// reference would make the snapshot test pass for the wrong reason.
store.versions.set(id, {
id,
definition_id: definitionId,
version,
spec: JSON.parse(JSON.stringify(spec)),
published_at: new Date(),
published_by: userId,
})
return id
}
const shapeRun = (r) => ({
...r,
definition_title: store.definitions.get(r.definition_id)?.title ?? null,
definition_slug: store.definitions.get(r.definition_id)?.slug ?? null,
version_number: store.versions.get(r.version_id)?.version ?? null,
})
runsDb.list = async ({ definitionId = null, status = null } = {}) =>
[...store.runs.values()]
.filter((r) => (!definitionId || r.definition_id === definitionId) && (!status || r.status === status))
.map(shapeRun)
runsDb.getById = async (id) => (store.runs.has(id) ? shapeRun(store.runs.get(id)) : undefined)
runsDb.listScheduledFor = async (definitionId) =>
[...store.runs.values()]
.filter((r) => r.definition_id === definitionId && r.status === 'scheduled' && !r.started_at)
.map((r) => ({ id: r.id, version_id: r.version_id, scheduled_for: r.scheduled_for }))
runsDb.repinScheduled = async (definitionId, versionId) => {
let moved = 0
for (const run of store.runs.values()) {
if (run.definition_id !== definitionId) continue
if (run.status !== 'scheduled' || run.started_at) continue
if (run.version_id === versionId) continue
run.version_id = versionId
moved += 1
}
return moved
}
runsDb.materialise = async (run) => {
const key = occurrenceKey(run.definition_id, run.scope, run.scheduled_for)
if (store.occurrences.has(key)) return null // the UNIQUE index, doing its job
store.occurrences.add(key)
const id = store.nextRun++
store.runs.set(id, {
id,
status: 'scheduled',
health: 'ok',
cleanup_status: 'not_required',
current_phase: null,
started_at: null,
ended_at: null,
last_error: null,
created_at: new Date(),
...run,
scheduled_for: new Date(run.scheduled_for),
rehearsal: Boolean(run.rehearsal),
})
return id
}
runsDb.findOccurrence = async (definitionId, scope, when) =>
[...store.runs.values()]
.filter(
(r) => occurrenceKey(r.definition_id, r.scope, r.scheduled_for) === occurrenceKey(definitionId, scope, when),
)
.map(shapeRun)[0]
runsDb.countActiveForDefinition = async (definitionId) =>
[...store.runs.values()].filter(
(r) =>
r.definition_id === definitionId &&
['scheduled', 'starting', 'running', 'paused', 'ending'].includes(r.status),
).length
stepsDb.listForRun = async (runId) =>
[...store.steps.values()].filter((s) => s.run_id === runId).sort((a, b) => a.seq - b.seq)
stepsDb.getById = async (id) => store.steps.get(id) || undefined
stepsDb.materialisePhase = async (runId, phase, steps) => {
steps.forEach((step, seq) => {
const taken = [...store.steps.values()].some(
(s) => s.run_id === runId && s.phase === phase && s.seq === seq,
)
if (taken) return
const id = store.nextStep++
store.steps.set(id, {
id,
run_id: runId,
phase,
seq,
action_id: step.actionId,
params: step.params || {},
action_version: step.actionVersion || 1,
status: 'pending',
due_at: null,
attempts: 0,
on_failure: step.onFailure || 'pause',
// The real materialiser stamps this from the row's own id, so the stub
// does too: a key that varied by attempt would defeat the whole retry
// story, and a stub that faked it would hide that.
idempotency_key: stepsDb.idempotencyKey(runId, id),
last_error: null,
started_at: null,
finished_at: null,
})
})
return stepsDb.listForRun(runId)
}
stepsDb.statusCounts = async (runId) => {
const counts = {}
for (const s of store.steps.values()) {
if (s.run_id === runId) counts[s.status] = (counts[s.status] || 0) + 1
}
return counts
}
// Phase 5 gave `runs.detail()` a third leg, and an unstubbed one is a real
// query against the dead port this file points at: the run-console test hung
// for ten seconds and then failed with ECONNREFUSED, saying nothing whatever
// about the route. **This is the third time a new leg has caught a stubbing
// file out** — Phase 4's expansion leg did it to `eventRunner.test.js`, where
// it merely made the file slow. Worth the comment: when the runner or a model
// gains a leg, every file that stubs the layer under it needs the stub.
gatesDb.listForRun = async (runId) => store.gates.filter((g) => g.run_id === runId)
logDb.listForRun = async (runId) => store.log.filter((l) => l.run_id === runId).reverse()
logDb.write = async ({ runId, stepId = null, kind, phase = null, detail = null }) => {
store.log.push({ id: store.log.length + 1, run_id: runId, step_id: stepId, kind, phase, detail, at: new Date() })
return true
}
seriesDb.list = async () => [...store.series.values()]
seriesDb.getById = async (id) => store.series.get(id) || null
seriesDb.exists = async (id) => store.series.has(id)
// The audit log is a side effect, not a subject: it writes to a real table and
// never throws into the request path, so the stub records and stays quiet.
activity.log = async (entry) => {
store.log.push({ audit: true, ...entry })
return true
}
}
// ── Fixtures ───────────────────────────────────────────────────────────────
beforeEach(() => {
registries._reset()
registries.registerCore()
installStubs()
})
afterEach(() => {
registries._reset()
restoreOriginals()
})
/** A module whose one action a definition can be built around. */
function registerDemoModule() {
const api = registries.stage('demo')
api.registerEventActions([
{
id: 'demo.world.change',
label: 'Change the world',
risk: 'change',
reversible: 'none',
params: [{ name: 'region', type: 'string', required: true, example: 'Yew' }],
perform: async () => ({ ok: true }),
},
])
registries.apply(api.staged)
}
const announceStep = (body = 'The gates open at dusk.') => ({
actionId: 'core.announce',
params: { leg: 'discord', body },
})
const draftBody = (over = {}) => ({
title: 'The Siege of Cove',
summary: 'An invasion, in three phases.',
timezone: 'Europe/Berlin',
spec: {
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [announceStep()] }],
},
...over,
})
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
}
}
async function call(handler, req) {
const res = mockRes()
let thrown = null
await handler({ body: {}, params: {}, query: {}, user: { id: 1 }, ...req }, res, (err) => {
thrown = err
})
if (thrown) throw thrown
return res
}
const createDraft = async (over = {}) => call(ctrl.create, { body: draftBody(over) })
// ── The catalog ────────────────────────────────────────────────────────────
test('the catalog serves the registry, callables stripped, with its vocabularies', async () => {
const res = await call(ctrl.catalog, {})
assert.equal(res.statusCode, 200)
assert.deepEqual(
res.body.actions.map((a) => a.id),
['core.announce', 'core.wait', 'core.cue'],
)
for (const action of res.body.actions) assert.equal(action.perform, undefined)
assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible'])
assert.deepEqual(res.body.onFailure, ['skip', 'pause', 'abort_run'])
// Phase 1 is honest about what it does not have: budget dimensions arrive with
// the module contract, so the catalog does not pretend to carry any.
assert.equal(res.body.budgets, undefined)
})
// ── Create, edit, slug ─────────────────────────────────────────────────────
test('a draft is created with a derived slug and no version', async () => {
const res = await createDraft()
assert.equal(res.statusCode, 201)
assert.equal(res.body.event.state, 'draft')
assert.equal(res.body.event.slug, 'the-siege-of-cove')
assert.equal(res.body.event.currentVersionId, null)
assert.equal(res.body.event.timezone, 'Europe/Berlin')
})
test('the slug is frozen after create — a retitle does not move the public page', async () => {
const created = await createDraft()
const id = created.body.event.id
const res = await call(ctrl.update, {
params: { id: String(id) },
body: draftBody({ title: 'The Second Siege of Cove' }),
})
assert.equal(res.statusCode, 200)
assert.equal(res.body.event.title, 'The Second Siege of Cove')
assert.equal(res.body.event.slug, 'the-siege-of-cove')
})
test('a bad timezone and a bad grace window are refused with both problems named', async () => {
const res = await createDraft({ timezone: 'Middle/Earth', graceSeconds: 5 })
assert.equal(res.statusCode, 400)
const joined = res.body.errors.join('\n')
assert.match(joined, /not an IANA zone name/)
assert.match(joined, /graceSeconds must be an integer/)
})
test('a step naming an unregistered action is refused at create', async () => {
const res = await createDraft({
spec: {
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'ghost.verb.do' }] }],
},
})
assert.equal(res.statusCode, 400)
assert.match(res.body.errors.join('\n'), /no module registers "ghost\.verb\.do"/)
})
// ── Publish ────────────────────────────────────────────────────────────────
test('publish snapshots the spec, and a later edit does not touch the version', async () => {
const created = await createDraft()
const id = created.body.event.id
const published = await call(ctrl.publish, { params: { id: String(id) } })
assert.equal(published.statusCode, 200)
assert.equal(published.body.version, 1)
assert.equal(published.body.event.state, 'ready')
// Edit the working copy afterwards.
await call(ctrl.update, {
params: { id: String(id) },
body: draftBody({
spec: {
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [announceStep('Something else entirely.')] }],
},
}),
})
const version = await versionsDb.getById(published.body.versionId)
assert.equal(version.spec.phases[0].steps[0].params.body, 'The gates open at dusk.')
// …and publishing again cuts version 2 rather than mutating version 1.
const again = await call(ctrl.publish, { params: { id: String(id) } })
assert.equal(again.body.version, 2)
const versions = await call(ctrl.listVersions, { params: { id: String(id) } })
assert.deepEqual(versions.body.versions.map((v) => v.version), [2, 1])
assert.deepEqual(versions.body.versions.map((v) => v.current), [true, false])
})
test('publish is refused when a step went dormant after the save that wrote it', async () => {
registerDemoModule()
const created = await createDraft({
spec: {
schedule: { kind: 'manual' },
phases: [
{ key: 'main', label: 'Main', steps: [{ actionId: 'demo.world.change', params: { region: 'Yew' } }] },
],
},
})
const id = created.body.event.id
// The module is uninstalled between the save and the publish.
registries._reset()
registries.registerCore()
const res = await call(ctrl.publish, { params: { id: String(id) } })
assert.equal(res.statusCode, 409)
assert.match(res.body.errors.join('\n'), /no module registers demo\.world\.change/)
// …and the definition is still editable, which is the other half of the rule:
// an uninstall must not be destructive after the fact.
const saved = await call(ctrl.update, { params: { id: String(id) }, body: { title: 'Renamed' } })
assert.equal(saved.statusCode, 200)
assert.equal(saved.body.event.spec.phases[0].steps[0].dormant, true)
})
test('an event with no steps does not publish', async () => {
const created = await createDraft({
spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] },
})
const res = await call(ctrl.publish, { params: { id: String(created.body.event.id) } })
assert.equal(res.statusCode, 400)
assert.match(res.body.errors.join('\n'), /no phase has any steps/)
})
// ── Runs ───────────────────────────────────────────────────────────────────
test('a draft has nothing to run', async () => {
const created = await createDraft()
const res = await call(ctrl.startRun, { params: { id: String(created.body.event.id) } })
assert.equal(res.statusCode, 409)
assert.match(res.body.errors.join('\n'), /no published version to run/)
})
test('a created run stays scheduled, with its first phase materialised', async () => {
const created = await createDraft()
const id = created.body.event.id
await call(ctrl.publish, { params: { id: String(id) } })
const started = await call(ctrl.startRun, { params: { id: String(id) } })
assert.equal(started.statusCode, 201)
assert.equal(started.body.created, true)
// The correct state for this phase, and it has to be visible as such rather
// than looking like a stall: there is no runner until Phase 2.
assert.equal(started.body.run.status, 'scheduled')
const detail = await call(ctrl.getRun, { params: { runId: String(started.body.run.id) } })
assert.equal(detail.body.steps.length, 1)
assert.equal(detail.body.steps[0].actionId, 'core.announce')
assert.equal(detail.body.steps[0].status, 'pending')
assert.deepEqual(detail.body.counts, { pending: 1 })
// Minted at materialisation, 40 hex, and a function of identity alone.
assert.match(detail.body.steps[0].idempotencyKey, /^[0-9a-f]{40}$/)
})
test('one occurrence, asked for twice, is one run', async () => {
const created = await createDraft()
const id = created.body.event.id
await call(ctrl.publish, { params: { id: String(id) } })
const when = '2026-10-31T20:00:00.000Z'
const first = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when } })
const second = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when } })
assert.equal(first.statusCode, 201)
assert.equal(first.body.created, true)
// Not an error — the unique index doing exactly what it is for. The existing
// row is the answer.
assert.equal(second.statusCode, 200)
assert.equal(second.body.created, false)
assert.equal(second.body.run.id, first.body.run.id)
const runs = await call(ctrl.listRuns, { query: { definitionId: String(id) } })
assert.equal(runs.body.runs.length, 1)
})
test('the same instant in two scopes is two runs', async () => {
const created = await createDraft()
const id = created.body.event.id
await call(ctrl.publish, { params: { id: String(id) } })
const when = '2026-10-31T20:00:00.000Z'
const a = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when, scope: 'europa' } })
const b = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when, scope: 'atlantic' } })
assert.equal(a.body.created, true)
assert.equal(b.body.created, true)
assert.notEqual(a.body.run.id, b.body.run.id)
})
test('the concurrency key is rendered from the run params', async () => {
const created = await createDraft({ concurrencyKey: 'invasion:{region}' })
const id = created.body.event.id
await call(ctrl.publish, { params: { id: String(id) } })
const res = await call(ctrl.startRun, {
params: { id: String(id) },
body: { params: { region: 'Yew' } },
})
assert.equal(res.body.run.concurrencyKey, 'invasion:Yew')
})
test('an unrendered placeholder is left standing rather than emptied', async () => {
// `invasion:` would collide with every other unrendered key on the deployment,
// which is the opposite of what a concurrency key is for.
const created = await createDraft({ concurrencyKey: 'invasion:{region}' })
const id = created.body.event.id
await call(ctrl.publish, { params: { id: String(id) } })
const res = await call(ctrl.startRun, { params: { id: String(id) } })
assert.equal(res.body.run.concurrencyKey, 'invasion:{region}')
})
test('the run log records the creation and the phase entry', async () => {
const created = await createDraft()
const id = created.body.event.id
await call(ctrl.publish, { params: { id: String(id) } })
const started = await call(ctrl.startRun, { params: { id: String(id) } })
const res = await call(ctrl.getRunLog, { params: { runId: String(started.body.run.id) } })
const kinds = res.body.log.map((l) => l.kind)
assert.ok(kinds.includes('run.created'))
assert.ok(kinds.includes('phase.entered'))
})
test('an unknown log kind is refused rather than stored', async () => {
// The closed set is enforced in the db layer, not by an ENUM, because it grows
// with almost every later phase — so it has to actually refuse.
restoreOriginals()
const ok = await logDb.write({ runId: 1, kind: 'not.a.kind' })
assert.equal(ok, false)
installStubs()
})
// ── Archive ────────────────────────────────────────────────────────────────
test('archiving is refused while a run is in flight, and allowed once it is not', async () => {
const created = await createDraft()
const id = created.body.event.id
await call(ctrl.publish, { params: { id: String(id) } })
const started = await call(ctrl.startRun, { params: { id: String(id) } })
const refused = await call(ctrl.archive, { params: { id: String(id) } })
assert.equal(refused.statusCode, 409)
assert.match(refused.body.errors.join('\n'), /still in flight/)
store.runs.get(started.body.run.id).status = 'completed'
const res = await call(ctrl.archive, { params: { id: String(id) } })
assert.equal(res.statusCode, 200)
assert.equal(res.body.event.state, 'archived')
})
test('an archived definition can be neither edited nor published', async () => {
const created = await createDraft()
const id = created.body.event.id
await call(ctrl.archive, { params: { id: String(id) } })
const edited = await call(ctrl.update, { params: { id: String(id) }, body: draftBody() })
assert.equal(edited.statusCode, 409)
const published = await call(ctrl.publish, { params: { id: String(id) } })
assert.equal(published.statusCode, 409)
})
test('the list filters by state, and an unknown id is 404 rather than 500', async () => {
await createDraft()
const second = await createDraft({ title: 'A Second Event' })
await call(ctrl.publish, { params: { id: String(second.body.event.id) } })
const ready = await call(ctrl.list, { query: { state: 'ready' } })
assert.deepEqual(ready.body.events.map((e) => e.title), ['A Second Event'])
const missing = await call(ctrl.get, { params: { id: '9999' } })
assert.equal(missing.statusCode, 404)
const bad = await call(ctrl.get, { params: { id: 'not-a-number' } })
assert.equal(bad.statusCode, 400)
})
test('publishing re-pins the occurrences that have not started, and says how many', async () => {
// The case an operator meets on their SECOND edit of any recurring event: a
// fortnight of occurrences is already on the calendar, each carrying the spec
// as it was. Left alone, an edit reaches none of them and the only recourse --
// cancelling each -- makes the occurrence vanish rather than come back, because
// a cancelled row still holds its slot in `uq_evrun_occurrence`.
const created = await createDraft()
const id = created.body.event.id
await call(ctrl.publish, { params: { id: String(id) } })
const ahead = await call(ctrl.startRun, {
params: { id: String(id) },
body: { scope: 'ahead', scheduledFor: '2026-12-24T20:00:00Z' },
})
assert.equal(ahead.statusCode, 201)
const aheadId = ahead.body.run.id
const v1 = store.runs.get(aheadId).version_id
// A second occurrence, this one already under way. Its pin is what makes it
// explicable afterwards, so it must not move.
const inFlight = await call(ctrl.startRun, {
params: { id: String(id) },
body: { scope: 'inflight', scheduledFor: '2026-12-25T20:00:00Z' },
})
const inFlightId = inFlight.body.run.id
store.runs.get(inFlightId).status = 'running'
store.runs.get(inFlightId).started_at = new Date()
const republished = await call(ctrl.publish, { params: { id: String(id) } })
assert.equal(republished.statusCode, 200)
assert.equal(republished.body.version, 2)
assert.equal(republished.body.repinned, 1)
assert.equal(store.runs.get(aheadId).version_id, republished.body.versionId)
assert.notEqual(store.runs.get(aheadId).version_id, v1)
assert.equal(store.runs.get(inFlightId).version_id, v1)
// The move is on the run's own log, because "which version did this actually
// use" is the first question an audit asks.
const line = store.log.find((l) => l.run_id === aheadId && l.detail?.repinned)
assert.equal(line.detail.fromVersionId, v1)
assert.equal(line.detail.toVersionId, republished.body.versionId)
})
test('re-publishing with nothing scheduled ahead re-pins nothing', async () => {
const created = await createDraft()
const id = created.body.event.id
await call(ctrl.publish, { params: { id: String(id) } })
const again = await call(ctrl.publish, { params: { id: String(id) } })
assert.equal(again.body.repinned, 0)
})