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

@@ -23,6 +23,7 @@
const db = require('./eventDefinitions.db')
const versionsDb = require('./eventVersions.db')
const runsDb = require('./eventRuns.db')
const logDb = require('./eventRunLog.db')
const seriesDb = require('./eventSeries.db')
const spec = require('../../events/spec')
const { slugify, uniqueSlug } = require('../teams/teamSlug')
@@ -229,7 +230,31 @@ async function publish(id, userId) {
const version = await versionsDb.nextVersion(id)
const versionId = await versionsDb.insert(id, version, checked.spec, userId)
await db.markReady(id, versionId, userId)
return { ok: true, versionId, version, definition: await db.getById(id) }
// Occurrences already materialised ahead of their instant move to the new
// version; ones that have begun do not (org lead, 2026-09-02). Logged per run
// rather than only counted, because "which version did this run actually use"
// is the first question an audit asks and the pin is no longer immutable while
// a run is still `scheduled`.
const pending = await runsDb.listScheduledFor(id)
const stale = pending.filter((r) => Number(r.version_id) !== Number(versionId))
const repinned = stale.length ? await runsDb.repinScheduled(id, versionId) : 0
for (const run of stale) {
await logDb.write({
runId: run.id,
kind: 'run.status',
detail: {
to: 'scheduled',
repinned: true,
fromVersionId: run.version_id,
toVersionId: versionId,
toVersion: version,
by: userId,
},
})
}
return { ok: true, versionId, version, repinned, definition: await db.getById(id) }
}
/**