feat(events): schedule, recurrence and the calendar (Phase 4)
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:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user