The four closed recurrence shapes computed in the definition's own IANA zone, a fourteen-day materialisation horizon with projections beyond it, series as a managed thing, and the admin calendar that replaces the plugin this feature exists to replace. An event now happens on its own. No schema change: Phase 1 built every column this needed. - events/recurrence.js is the ONE place an occurrence is computed, so the runner's expansion and the calendar's forecast cannot disagree. No date library added — Node ships the tzdata one would vendor, behind Intl. - The runner's materialise leg is now two halves: expand, then sweep. The window starts at `now - grace`, so an occurrence nobody could have seen is never invented retroactively; the horizon is what makes the missed sweep mean anything for a recurrence. - Publishing is the schedule switch and archiving turns it off, and publishing re-pins every occurrence that has not started. - A projection is never drawn over an instant a run occupies, so a cancelled occurrence does not reappear as a forecast. 54 new tests, incl. the DST fixture set the plan asked for and three new statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail (pre-existing CRLF). Walked end to end on the local review stack. Docs: RunicGateway/docs#PENDING Co-Authored-By: Claude <noreply@anthropic.com>
392 lines
16 KiB
JavaScript
392 lines
16 KiB
JavaScript
// ── 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')
|
|
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],
|
|
]) {
|
|
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,
|
|
})
|
|
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 } })
|
|
}
|
|
|
|
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')
|
|
})
|