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:
@@ -97,6 +97,92 @@ const materialise = async (run) => {
|
||||
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every run whose instant falls inside a window — the calendar's real half.
|
||||
*
|
||||
* Ascending, unlike the admin run list: a calendar is read forwards. The join
|
||||
* reaches the series so a month can be filtered to one arc without a second
|
||||
* round trip, and `d.timezone` is NOT what comes back — `r.timezone` is, because
|
||||
* a run records the zone it was COMPUTED in and a definition's zone can be
|
||||
* edited afterwards.
|
||||
*/
|
||||
const listInWindow = async ({ from, to, status = null, scope = null, seriesId = null, limit = 500 } = {}) => {
|
||||
const where = ['r.scheduled_for >= ?', 'r.scheduled_for < ?']
|
||||
const args = [from, to]
|
||||
if (status) {
|
||||
where.push('r.status = ?')
|
||||
args.push(status)
|
||||
}
|
||||
if (scope !== null && scope !== undefined) {
|
||||
where.push('r.scope = ?')
|
||||
args.push(scope)
|
||||
}
|
||||
if (seriesId) {
|
||||
where.push('d.series_id = ?')
|
||||
args.push(seriesId)
|
||||
}
|
||||
const n = Math.min(Math.max(Number(limit) || 500, 1), 1000)
|
||||
const rows = await query(
|
||||
`SELECT r.*, d.title AS definition_title, d.slug AS definition_slug,
|
||||
d.series_id AS series_id, se.name AS series_name, se.slug AS series_slug,
|
||||
v.version AS version_number,
|
||||
(SELECT COUNT(*) FROM event_run_steps s
|
||||
WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
|
||||
FROM event_runs r
|
||||
JOIN event_definitions d ON d.id = r.definition_id
|
||||
JOIN event_versions v ON v.id = r.version_id
|
||||
LEFT JOIN event_series se ON se.id = d.series_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY r.scheduled_for, r.id
|
||||
LIMIT ${n}`,
|
||||
args,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Point every not-yet-started occurrence of a definition at a new version.
|
||||
*
|
||||
* Publishing calls this, and the guard is the whole statement: `status =
|
||||
* 'scheduled'` and `started_at IS NULL`. A run that has begun keeps the version
|
||||
* it pinned, for ever, because that pin is what makes it explicable afterwards
|
||||
* -- and a run that has NOT begun has nothing to explain yet.
|
||||
*
|
||||
* **Why re-pinning is the right answer and doing nothing is not** (org lead,
|
||||
* 2026-09-02): occurrences are materialised a fortnight ahead, so on the day an
|
||||
* editor fixes a typo there are already fourteen days of rows carrying the old
|
||||
* spec. Left alone, the fix reaches none of them, and the operator's only
|
||||
* recourse -- cancelling each one -- is worse: a cancelled row still holds its
|
||||
* slot in `uq_evrun_occurrence`, so the occurrence does not come back on the new
|
||||
* version, it disappears.
|
||||
*
|
||||
* Answers how many were moved, so publish can say so rather than leaving it to
|
||||
* be noticed.
|
||||
*/
|
||||
const repinScheduled = async (definitionId, versionId) => {
|
||||
const result = await query(
|
||||
`UPDATE event_runs
|
||||
SET version_id = ?
|
||||
WHERE definition_id = ?
|
||||
AND status = 'scheduled'
|
||||
AND started_at IS NULL
|
||||
AND version_id <> ?`,
|
||||
[versionId, definitionId, versionId],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
/** The scheduled, not-yet-started occurrences a re-pin would move. */
|
||||
const listScheduledFor = async (definitionId) =>
|
||||
(
|
||||
await query(
|
||||
`SELECT id, version_id, scheduled_for FROM event_runs
|
||||
WHERE definition_id = ? AND status = 'scheduled' AND started_at IS NULL
|
||||
ORDER BY scheduled_for`,
|
||||
[definitionId],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
/** The occurrence the unique key names, whether or not this call created it. */
|
||||
const findOccurrence = async (definitionId, scope, scheduledFor) => {
|
||||
const [row] = await query(
|
||||
@@ -370,6 +456,9 @@ module.exports = {
|
||||
list,
|
||||
getById,
|
||||
materialise,
|
||||
listInWindow,
|
||||
repinScheduled,
|
||||
listScheduledFor,
|
||||
findOccurrence,
|
||||
countActiveForDefinition,
|
||||
findDue,
|
||||
|
||||
Reference in New Issue
Block a user