// ── Expansion and the calendar (EVENTS_PLAN.md Phase 4) ──────────────────── // // The phase's shipped claim: **a published definition with a recurrence produces // occurrences on its own, and the calendar shows the ones that exist beside the // ones that will.** The arithmetic underneath is proved separately in // `eventRecurrence.test.js`; this file is about the two decisions the org lead // took on 2026-09-02 and the properties they imply: // // • occurrences become REAL ROWS inside a fourteen-day horizon, and beyond it // the calendar projects rather than materialising // • a projection is never emitted for an instant a run already occupies — so // the fortnight inside the horizon is not drawn twice, and a CANCELLED // occurrence does not come back as a forecast // • expansion looks forward from `now - grace` only, so an occurrence nobody // could ever have seen is not invented retroactively // • only `ready` definitions expand: publishing IS the schedule switch (§E) // and archiving is how an operator turns one off // • expansion is idempotent, because it runs every fifteen seconds for ever // // Stubbed at the `.db` layer, the shape `eventRunner.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 runner = require('../src/utils/eventRunner') const calendarModel = require('../src/model/events/eventCalendar.model') const definitionsDb = require('../src/model/events/eventDefinitions.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') // Phase 6: `runsModel.create` prices the version against the switchboard and // seeds the run's budget, so expansion now reaches two more tables. Unstubbed // they are a ten-second ECONNREFUSED per occurrence. const settingsDb = require('../src/model/events/eventActionSettings.db') const budgetDb = require('../src/model/events/eventRunBudget.db') const versionsDb = require('../src/model/events/eventVersions.db') const db = require('../src/utils/db') after(() => db.close()) // A Tuesday. Chosen so a "friday" schedule has its first occurrence three days // out — inside the horizon, but not today, which is what keeps "materialised" // and "due" from being confusable in these fixtures. const NOW = new Date('2026-09-01T12:00:00Z') const SPEC = { schedule: { kind: 'weekly', days: ['friday'], time: '20:00' }, phases: [{ key: 'main', label: 'Main', steps: [] }], } let store const originals = {} for (const [name, mod] of [ ['definitionsDb', definitionsDb], ['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['settingsDb', settingsDb], ['budgetDb', budgetDb], ]) { originals[name] = { mod, fns: { ...mod } } } const restoreOriginals = () => { for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns) } const clone = (o) => JSON.parse(JSON.stringify(o)) /** One `ready` definition with a published version carrying `spec`. */ function addDefinition(id, overrides = {}) { const definition = { id, title: `Event ${id}`, slug: `event-${id}`, state: 'ready', timezone: 'UTC', grace_seconds: 900, concurrency_key: null, current_version_id: id * 100, series_id: null, series_name: null, series_slug: null, spec: clone(SPEC), ...overrides, } store.definitions.set(id, definition) store.versions.set(definition.current_version_id, { id: definition.current_version_id, definition_id: id, version: 1, spec: definition.spec, // Verified by default (Phase 6). §K holds a scheduled occurrence of a version // nobody has dry-run, so an unverified fixture would make every test in this // file assert nothing about recurrence and everything about that one gate. // The gate has its own test below, where an occurrence is what is being // measured rather than what is in the way. verified_at: new Date('2026-08-01T00:00:00Z'), verified_by: 1, ...(overrides.version || {}), }) return definition } function installStubs() { store = { definitions: new Map(), versions: new Map(), runs: [], steps: [], log: [], nextRunId: 1 } Object.assign(definitionsDb, { findSchedulable: async () => [...store.definitions.values()] .filter((d) => d.state === 'ready' && d.current_version_id) .map((d) => ({ ...d, version_spec: store.versions.get(d.current_version_id)?.spec || null })), getById: async (id) => store.definitions.get(id) || null, list: async () => [...store.definitions.values()], }) Object.assign(versionsDb, { getById: async (id) => store.versions.get(id) || null }) Object.assign(runsDb, { materialise: async (run) => { const at = new Date(run.scheduled_for).getTime() // The unique index, in memory: one row per (definition, scope, instant). const clash = store.runs.find( (r) => r.definition_id === run.definition_id && r.scope === (run.scope || '') && new Date(r.scheduled_for).getTime() === at, ) if (clash) return null const id = store.nextRunId++ const definition = store.definitions.get(run.definition_id) store.runs.push({ ...run, id, scope: run.scope || '', status: 'scheduled', health: 'ok', waiting_steps: 0, definition_title: definition?.title, definition_slug: definition?.slug, series_id: definition?.series_id ?? null, series_name: definition?.series_name ?? null, series_slug: definition?.series_slug ?? null, version_number: 1, }) return id }, getById: async (id) => store.runs.find((r) => r.id === id) || null, findOccurrence: async (definitionId, scope, at) => store.runs.find( (r) => r.definition_id === definitionId && r.scope === (scope || '') && new Date(r.scheduled_for).getTime() === new Date(at).getTime(), ) || null, listInWindow: async ({ from, to, status = null, scope = null, seriesId = null }) => store.runs .filter((r) => { const at = new Date(r.scheduled_for).getTime() if (at < new Date(from).getTime() || at >= new Date(to).getTime()) return false if (status && r.status !== status) return false if (scope !== null && scope !== undefined && r.scope !== scope) return false if (seriesId && Number(r.series_id) !== Number(seriesId)) return false return true }) .sort((a, b) => new Date(a.scheduled_for) - new Date(b.scheduled_for)), }) Object.assign(stepsDb, { materialisePhase: async () => [] }) Object.assign(logDb, { write: async (line) => { store.log.push(line); return 1 } }) // Phase 6. No stored switch anywhere in this file: an empty switchboard is a // fresh deployment, and expansion is not what this file is measuring. Object.assign(settingsDb, { byIds: async () => new Map(), get: async () => null }) Object.assign(budgetDb, { seed: async () => 0, forRun: async () => [] }) } beforeEach(() => { registries._reset() registries.registerCore() installStubs() }) afterEach(restoreOriginals) const instants = () => store.runs.map((r) => new Date(r.scheduled_for).toISOString()).sort() // ── Expansion ────────────────────────────────────────────────────────────── test('a weekly definition materialises exactly the occurrences inside the horizon', async () => { addDefinition(1) const created = await runner.expandSchedules(NOW) // 1 September 2026 is a Tuesday. Fridays inside 14 days: the 4th and the 11th. assert.equal(created, 2) assert.deepEqual(instants(), ['2026-09-04T20:00:00.000Z', '2026-09-11T20:00:00.000Z']) }) test('expansion is idempotent — running it again creates nothing', async () => { // The property the whole design leans on: this runs every fifteen seconds for // ever. `INSERT IGNORE` against the occurrence key is what makes that free, // and a second call that created rows would be a duplicate event, not a // duplicate row. addDefinition(1) assert.equal(await runner.expandSchedules(NOW), 2) assert.equal(await runner.expandSchedules(NOW), 0) assert.equal(await runner.expandSchedules(new Date(NOW.getTime() + 60_000)), 0) assert.equal(store.runs.length, 2) }) test('only `ready` definitions expand — publishing is the switch, archiving turns it off', async () => { addDefinition(1, { state: 'draft' }) addDefinition(2, { state: 'archived' }) addDefinition(3, { state: 'ready' }) await runner.expandSchedules(NOW) assert.deepEqual([...new Set(store.runs.map((r) => r.definition_id))], [3]) }) test('a draft edit cannot materialise anything — the VERSION spec is what expands', async () => { // The definition's working copy says daily; the published version says weekly. // A half-typed recurrence an author is midway through must never produce a run. const definition = addDefinition(1) definition.spec = { schedule: { kind: 'weekly', days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'], time: '20:00' }, phases: SPEC.phases, } await runner.expandSchedules(NOW) assert.equal(store.runs.length, 2) }) test('a manual definition expands to nothing at all', async () => { addDefinition(1, { spec: { schedule: { kind: 'manual' }, phases: SPEC.phases } }) store.versions.get(100).spec = store.definitions.get(1).spec assert.equal(await runner.expandSchedules(NOW), 0) assert.equal(store.runs.length, 0) }) test('an occurrence older than the grace window is never materialised at all', async () => { // Not materialised-then-swept. A row nobody could ever have seen or cancelled // is not history, and writing one would put a `missed` event on the calendar // for a date on which this deployment had no such event. The horizon is what // makes the missed sweep meaningful instead: a real outage finds rows already // there, because they were written a fortnight early. addDefinition(1, { grace_seconds: 900 }) // A Monday, three days after the Friday occurrence — far outside the grace. await runner.expandSchedules(new Date('2026-09-07T12:00:00Z')) assert.ok(!instants().includes('2026-09-04T20:00:00.000Z')) }) test('an occurrence still inside the grace window IS materialised', async () => { // The case this rule exists for: a definition published four minutes before // its own first occurrence. `now - grace` is the window start, so the // occurrence that has only just passed is still created and still startable. addDefinition(1, { grace_seconds: 3600 }) await runner.expandSchedules(new Date('2026-09-04T20:10:00Z')) assert.ok(instants().includes('2026-09-04T20:00:00.000Z')) }) test('a DST-adjusted occurrence records WHY its clock reads oddly', async () => { // Discovering daylight saving at 3am on the last Sunday in October is the // failure this line exists to prevent. addDefinition(1, { timezone: 'Europe/Berlin', spec: { schedule: { kind: 'weekly', days: ['sunday'], time: '02:30' }, phases: SPEC.phases }, }) store.versions.get(100).spec = store.definitions.get(1).spec await runner.expandSchedules(new Date('2026-03-22T12:00:00Z')) const adjusted = store.log.find((l) => l.detail?.dstAdjusted) assert.equal(adjusted.detail.dstAdjusted, 'gap') assert.equal(adjusted.detail.timezone, 'Europe/Berlin') assert.ok(instants().includes('2026-03-29T01:00:00.000Z')) }) test('a definition whose spec is nonsense is skipped, and the sweep carries on', async () => { // A spec written straight into the database with a shape the validator would // have refused is a bad row, not a bad tick. addDefinition(1, { spec: { schedule: { kind: 'weekly', days: ['froday'], time: '20:00' }, phases: SPEC.phases } }) store.versions.get(100).spec = store.definitions.get(1).spec addDefinition(2) const created = await runner.expandSchedules(NOW) assert.equal(created, 2) assert.deepEqual([...new Set(store.runs.map((r) => r.definition_id))], [2]) }) test('every materialised occurrence is marked as coming from the schedule', async () => { // `started_by` is NULL for a scheduled occurrence and for one an admin started // whose account has since gone, so the log is the only place the two are told // apart. addDefinition(1) await runner.expandSchedules(NOW) const created = store.log.filter((l) => l.kind === 'run.created' && l.detail?.source) assert.equal(created.length, 2) for (const line of created) { assert.equal(line.detail.source, 'schedule') assert.equal(line.detail.by, null) } }) // ── The calendar ─────────────────────────────────────────────────────────── test('inside the horizon the calendar shows runs; beyond it, projections', async () => { addDefinition(1) await runner.expandSchedules(NOW) const result = await calendarModel.calendar({ from: new Date('2026-09-01T00:00:00Z'), to: new Date('2026-10-01T00:00:00Z'), now: NOW, }) const kinds = result.entries.map((e) => `${e.kind} ${new Date(e.scheduledFor).toISOString().slice(0, 10)}`) assert.deepEqual(kinds, [ 'run 2026-09-04', 'run 2026-09-11', 'projected 2026-09-18', 'projected 2026-09-25', ]) // The forecast is arithmetic and says so: no row, nothing to open. for (const entry of result.entries.filter((e) => e.kind === 'projected')) { assert.equal(entry.runId, null) assert.equal(entry.status, null) } }) test('a projection is never drawn over an instant a run already occupies', async () => { addDefinition(1) await runner.expandSchedules(NOW) const result = await calendarModel.calendar({ from: new Date('2026-09-01T00:00:00Z'), to: new Date('2026-09-15T00:00:00Z'), now: NOW, }) assert.equal(result.entries.length, 2) assert.ok(result.entries.every((e) => e.kind === 'run')) }) test('a CANCELLED occurrence does not come back as a forecast', async () => { // The same rule, and the case it earns its keep on. An operator who called an // event off must not find it on the calendar again ten seconds later looking // like it is still coming. addDefinition(1) await runner.expandSchedules(NOW) store.runs[0].status = 'cancelled' const result = await calendarModel.calendar({ from: new Date('2026-09-01T00:00:00Z'), to: new Date('2026-09-15T00:00:00Z'), now: NOW, }) const onTheDay = result.entries.filter((e) => new Date(e.scheduledFor).toISOString().startsWith('2026-09-04')) assert.equal(onTheDay.length, 1) assert.equal(onTheDay[0].kind, 'run') assert.equal(onTheDay[0].status, 'cancelled') }) test('a status filter suppresses projections, because a forecast has no status', async () => { addDefinition(1) await runner.expandSchedules(NOW) const result = await calendarModel.calendar({ from: new Date('2026-09-01T00:00:00Z'), to: new Date('2026-10-01T00:00:00Z'), status: 'scheduled', now: NOW, }) assert.ok(result.entries.every((e) => e.kind === 'run')) assert.equal(result.entries.length, 2) }) test('a series filter narrows runs and projections alike', async () => { addDefinition(1, { series_id: 7, series_name: 'Royal Spy Mission' }) addDefinition(2, { series_id: 9, series_name: 'Something Else' }) await runner.expandSchedules(NOW) const result = await calendarModel.calendar({ from: new Date('2026-09-01T00:00:00Z'), to: new Date('2026-10-01T00:00:00Z'), seriesId: 7, now: NOW, }) assert.ok(result.entries.length > 2) assert.ok(result.entries.every((e) => e.seriesName === 'Royal Spy Mission')) assert.ok(result.entries.some((e) => e.kind === 'projected')) }) test('the window is bounded, inverted windows are refused, and the horizon is reported', async () => { const wide = await calendarModel.calendar({ from: new Date('2026-01-01T00:00:00Z'), to: new Date('2027-01-01T00:00:00Z'), now: NOW, }) assert.equal(wide.ok, false) assert.equal(wide.status, 400) assert.match(wide.errors.join(' '), /at most 92 days/) const inverted = await calendarModel.calendar({ from: new Date('2026-09-10T00:00:00Z'), to: new Date('2026-09-01T00:00:00Z'), now: NOW, }) assert.equal(inverted.ok, false) const fine = await calendarModel.calendar({ from: new Date('2026-09-01T00:00:00Z'), to: new Date('2026-09-15T00:00:00Z'), horizonDays: 14, now: NOW, }) assert.equal(fine.ok, true) assert.equal(fine.horizon.toISOString(), '2026-09-15T12:00:00.000Z') })