feat(events): schedule, recurrence and the calendar (Phase 4)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 13m26s

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>
This commit is contained in:
2026-09-02 16:10:16 -05:00
parent a481248bc0
commit 6e73660b52
30 changed files with 3722 additions and 77 deletions

View File

@@ -0,0 +1,391 @@
// ── 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')
})