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

@@ -160,6 +160,21 @@ function installStubs() {
.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
@@ -595,3 +610,56 @@ test('the list filters by state, and an unknown id is 404 rather than 500', asyn
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)
})