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

@@ -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,