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:
@@ -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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user