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:
335
server/src/events/recurrence.js
Normal file
335
server/src/events/recurrence.js
Normal file
@@ -0,0 +1,335 @@
|
||||
// ── Occurrence arithmetic, in the event's own timezone ─────────────────────
|
||||
//
|
||||
// EVENTS.md §E, "Two scheduling decisions the calendar forces", and Phase 4 of
|
||||
// EVENTS_PLAN.md. Given a closed recurrence shape, an IANA zone and a window,
|
||||
// this file answers *which UTC instants* an event happens at. Nothing else
|
||||
// computes an occurrence; the runner materialises what this returns and the
|
||||
// calendar projects what this returns, so there is exactly one arithmetic to be
|
||||
// wrong.
|
||||
//
|
||||
// **Why there is no library here.** The server's dependency tree has no date
|
||||
// library at all — no luxon, no date-fns, no tz package (check `package.json`
|
||||
// before adding one). What it does have is Node's own full tzdata behind
|
||||
// `Intl.DateTimeFormat`, which is the same database a library would ship a copy
|
||||
// of and is already what `eventDefinitions.model.js` validates a zone name
|
||||
// against. So the arithmetic is: *format an instant into the zone's wall clock*
|
||||
// (which `Intl` does exactly) and invert that mapping by search. Everything
|
||||
// below is that one idea.
|
||||
//
|
||||
// **Why not cron.** Decided in §E and restated in the plan: there is no parser
|
||||
// in the tree, the only precedent is in the bot (another process), and a cron
|
||||
// string is the one field an operator cannot proofread. Four closed shapes
|
||||
// render as a form, and a form is checkable.
|
||||
//
|
||||
// **The two DST rules** (org lead, 2026-09-02), which exist because a weekly
|
||||
// 02:30 event in `Europe/Berlin` is a real thing an operator will author:
|
||||
//
|
||||
// - A **nonexistent** local time — the spring-forward gap — steps forward to the
|
||||
// first wall clock that does exist. 02:30 becomes 03:00, not 03:30: the event
|
||||
// happens as close to the authored time as the calendar allows.
|
||||
// - An **ambiguous** local time — the fall-back hour, which happens twice —
|
||||
// takes the FIRST, the pre-transition offset.
|
||||
//
|
||||
// Both are reported back as `adjusted`, so a run can record why its clock reads
|
||||
// oddly rather than leaving an operator to discover DST for themselves at 3am.
|
||||
// Neither rule ever drops an occurrence: a weekly event happens every week.
|
||||
|
||||
// Indexed to match `Date#getUTCDay`, which is what the civil-calendar helpers
|
||||
// below return. Names rather than numbers everywhere an operator can see them —
|
||||
// `days: ['friday']` is proofreadable and `days: [5]` is not, which is the same
|
||||
// argument that rejected cron.
|
||||
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
|
||||
|
||||
// `nth: -1` is "the last one in the month", and it is not a synonym for 4: a
|
||||
// month with five Fridays has a last Friday that is not the fourth. 1..4 always
|
||||
// exist in every month (1 + 6 + 21 = 28), so there is deliberately no fifth and
|
||||
// therefore no absent-occurrence case to define (org lead, 2026-09-02).
|
||||
const MONTHLY_NTH = [1, 2, 3, 4, -1]
|
||||
|
||||
const TIME_RE = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||
const AT_RE = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[T ]([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||
|
||||
const MINUTE_MS = 60_000
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
// No real DST gap exceeds two hours (Lord Howe's is 30 minutes; the largest
|
||||
// historical jumps are a day, and those are line-of-date changes rather than
|
||||
// gaps in the local clock). Four hours is a bound, not an expectation: it stops
|
||||
// a malformed zone turning the search into a hang.
|
||||
const MAX_GAP_MINUTES = 240
|
||||
|
||||
// Bounds on what one call may return. A projection window is operator-supplied
|
||||
// (the calendar's month, the horizon), and an unbounded expansion of a daily
|
||||
// schedule across a decade is how a calendar request becomes an outage.
|
||||
const MAX_OCCURRENCES = 500
|
||||
|
||||
const formatters = new Map()
|
||||
|
||||
function formatterFor(zone) {
|
||||
let f = formatters.get(zone)
|
||||
if (!f) {
|
||||
// `hourCycle: 'h23'` rather than `hour12: false`, which renders midnight as
|
||||
// hour 24 in some ICU versions and would put every midnight event on the
|
||||
// previous day.
|
||||
f = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
formatters.set(zone, f)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
/** The wall clock an instant reads as, in this zone. */
|
||||
function wallPartsAt(zone, ms) {
|
||||
const parts = formatterFor(zone).formatToParts(new Date(ms))
|
||||
const get = (type) => Number(parts.find((p) => p.type === type)?.value)
|
||||
return {
|
||||
y: get('year'),
|
||||
m: get('month'),
|
||||
d: get('day'),
|
||||
h: get('hour'),
|
||||
mi: get('minute'),
|
||||
s: get('second'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* That same wall clock as a number, by reading it as though it were UTC.
|
||||
*
|
||||
* This is the trick the whole file rests on: two wall clocks are equal exactly
|
||||
* when these numbers are, and `wallMs - instant` is the zone's offset at that
|
||||
* instant. It is never a real instant and must not be used as one.
|
||||
*/
|
||||
function wallMs(zone, ms) {
|
||||
const p = wallPartsAt(zone, ms)
|
||||
return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s)
|
||||
}
|
||||
|
||||
const offsetMs = (zone, ms) => wallMs(zone, ms) - ms
|
||||
|
||||
/**
|
||||
* Every instant that reads as this wall clock in this zone, earliest first.
|
||||
*
|
||||
* Ordinarily one. Two in the fall-back hour, none in the spring-forward gap —
|
||||
* and the length of this array is how the caller tells those three apart.
|
||||
*
|
||||
* Sampling the offset a day either side is what makes it correct across a
|
||||
* transition: subtracting each candidate offset gives the two instants worth
|
||||
* testing, and the test is whether the instant formats back to what was asked.
|
||||
*/
|
||||
function instantsForWall(zone, target) {
|
||||
const candidates = new Set([
|
||||
target - offsetMs(zone, target - DAY_MS),
|
||||
target - offsetMs(zone, target + DAY_MS),
|
||||
])
|
||||
const valid = []
|
||||
for (const ms of candidates) {
|
||||
if (wallMs(zone, ms) === target) valid.push(ms)
|
||||
}
|
||||
return valid.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local wall clock to a UTC instant, applying the two DST rules.
|
||||
*
|
||||
* `{ at, adjusted, shiftMinutes }` — `adjusted` is `null` on an ordinary day,
|
||||
* `'gap'` when the authored time did not exist and was stepped forward, and
|
||||
* `'ambiguous'` when it happened twice and the first was taken.
|
||||
*/
|
||||
function resolveWall(zone, y, m, d, h, mi) {
|
||||
const target = Date.UTC(y, m - 1, d, h, mi, 0)
|
||||
const valid = instantsForWall(zone, target)
|
||||
if (valid.length === 1) return { at: new Date(valid[0]), adjusted: null, shiftMinutes: 0 }
|
||||
if (valid.length > 1) return { at: new Date(valid[0]), adjusted: 'ambiguous', shiftMinutes: 0 }
|
||||
|
||||
// The gap. Step the WALL CLOCK forward — not the instant — until it lands on
|
||||
// a time that exists, which is the first instant after the transition.
|
||||
for (let step = 1; step <= MAX_GAP_MINUTES; step += 1) {
|
||||
const shifted = instantsForWall(zone, target + step * MINUTE_MS)
|
||||
if (shifted.length) return { at: new Date(shifted[0]), adjusted: 'gap', shiftMinutes: step }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── The civil calendar ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Dates with no zone attached: "the 14th of September" as a thing to iterate,
|
||||
// before any question of what instant it starts at. `Date.UTC` is used purely
|
||||
// as calendar arithmetic here and none of these numbers is an instant.
|
||||
|
||||
const dayIndex = (y, m, d) => Date.UTC(y, m - 1, d) / DAY_MS
|
||||
|
||||
function civilFromIndex(n) {
|
||||
const dt = new Date(n * DAY_MS)
|
||||
return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() }
|
||||
}
|
||||
|
||||
const weekdayOf = (y, m, d) => new Date(Date.UTC(y, m - 1, d)).getUTCDay()
|
||||
|
||||
const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate()
|
||||
|
||||
/** Is this a real date? `2026-02-30` parses as a string and is not a day. */
|
||||
const isRealDate = (y, m, d) => m >= 1 && m <= 12 && d >= 1 && d <= daysInMonth(y, m)
|
||||
|
||||
/**
|
||||
* The day of the month that is the nth (or last) given weekday.
|
||||
*
|
||||
* `nth` is 1..4 or -1. Answers `null` only for an nth that cannot exist, which
|
||||
* the validated shapes never produce — the guard is here so that a spec written
|
||||
* by hand into the database cannot make the runner throw.
|
||||
*/
|
||||
function nthWeekdayDay(y, m, weekday, nth) {
|
||||
const last = daysInMonth(y, m)
|
||||
if (nth === -1) {
|
||||
const back = (weekdayOf(y, m, last) - weekday + 7) % 7
|
||||
return last - back
|
||||
}
|
||||
const forward = (weekday - weekdayOf(y, m, 1) + 7) % 7
|
||||
const day = 1 + forward + (nth - 1) * 7
|
||||
return day <= last ? day : null
|
||||
}
|
||||
|
||||
// ── Expansion ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every occurrence of `schedule` in `[from, to)`, earliest first.
|
||||
*
|
||||
* `[{ at: Date, adjusted, shiftMinutes }]`. `manual` answers `[]` — it is the
|
||||
* shape that means "there is no recurrence", and an admin's own
|
||||
* `POST /:id/runs` is the only thing that creates one of its occurrences.
|
||||
*
|
||||
* The window is in INSTANTS and the walk is in LOCAL DAYS, which is why each
|
||||
* walk starts a day early and ends a day late: a local day can begin up to
|
||||
* fourteen hours either side of the same UTC day.
|
||||
*/
|
||||
function occurrencesBetween(schedule, zone, from, to, { limit = MAX_OCCURRENCES } = {}) {
|
||||
const fromMs = from instanceof Date ? from.getTime() : Number(from)
|
||||
const toMs = to instanceof Date ? to.getTime() : Number(to)
|
||||
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return []
|
||||
if (!schedule || typeof schedule !== 'object') return []
|
||||
|
||||
const cap = Math.min(Math.max(Number(limit) || MAX_OCCURRENCES, 1), MAX_OCCURRENCES)
|
||||
const out = []
|
||||
const keep = (resolved) => {
|
||||
if (!resolved) return
|
||||
const t = resolved.at.getTime()
|
||||
if (t >= fromMs && t < toMs && out.length < cap) out.push(resolved)
|
||||
}
|
||||
|
||||
if (schedule.kind === 'manual') return []
|
||||
|
||||
if (schedule.kind === 'once') {
|
||||
const m = AT_RE.exec(String(schedule.at || ''))
|
||||
if (!m) return []
|
||||
keep(resolveWall(zone, Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])))
|
||||
return out
|
||||
}
|
||||
|
||||
const time = TIME_RE.exec(String(schedule.time || ''))
|
||||
if (!time) return []
|
||||
const hour = Number(time[1])
|
||||
const minute = Number(time[2])
|
||||
|
||||
if (schedule.kind === 'weekly') {
|
||||
const wanted = new Set(
|
||||
(schedule.days || []).map((d) => WEEKDAYS.indexOf(String(d))).filter((i) => i >= 0),
|
||||
)
|
||||
if (!wanted.size) return []
|
||||
const first = wallPartsAt(zone, fromMs)
|
||||
const last = wallPartsAt(zone, toMs)
|
||||
const startDay = dayIndex(first.y, first.m, first.d) - 1
|
||||
const endDay = dayIndex(last.y, last.m, last.d) + 1
|
||||
for (let n = startDay; n <= endDay && out.length < cap; n += 1) {
|
||||
const { y, m, d } = civilFromIndex(n)
|
||||
if (wanted.has(weekdayOf(y, m, d))) keep(resolveWall(zone, y, m, d, hour, minute))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
if (schedule.kind === 'monthly') {
|
||||
const weekday = WEEKDAYS.indexOf(String(schedule.weekday))
|
||||
const nth = Number(schedule.nth)
|
||||
if (weekday < 0 || !MONTHLY_NTH.includes(nth)) return []
|
||||
const first = wallPartsAt(zone, fromMs)
|
||||
const last = wallPartsAt(zone, toMs)
|
||||
// Months as a single running count, so a window crossing a new year is not
|
||||
// a special case.
|
||||
const startMonth = first.y * 12 + (first.m - 1) - 1
|
||||
const endMonth = last.y * 12 + (last.m - 1) + 1
|
||||
for (let n = startMonth; n <= endMonth && out.length < cap; n += 1) {
|
||||
const y = Math.floor(n / 12)
|
||||
const m = (n % 12) + 1
|
||||
const day = nthWeekdayDay(y, m, weekday, nth)
|
||||
if (day) keep(resolveWall(zone, y, m, day, hour, minute))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/** The next occurrence at or after `from`, or null. A bounded look-ahead. */
|
||||
function nextOccurrence(schedule, zone, from, { withinDays = 400 } = {}) {
|
||||
const fromMs = from instanceof Date ? from.getTime() : Number(from)
|
||||
const [first] = occurrencesBetween(schedule, zone, fromMs, fromMs + withinDays * DAY_MS, {
|
||||
limit: 1,
|
||||
})
|
||||
return first || null
|
||||
}
|
||||
|
||||
/**
|
||||
* How a schedule reads to a person, in the event's own zone.
|
||||
*
|
||||
* Server-side because two surfaces need the same sentence — the calendar's list
|
||||
* and the run's own record of why it exists — and because the client's copy in
|
||||
* `eventAuthoring.js` is a mirror that is allowed to drift on wording but not on
|
||||
* meaning.
|
||||
*/
|
||||
function describe(schedule, zone = 'UTC') {
|
||||
if (!schedule || typeof schedule !== 'object') return 'No schedule'
|
||||
const cap = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1)
|
||||
const nthLabel = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', '-1': 'last' }
|
||||
switch (schedule.kind) {
|
||||
case 'manual':
|
||||
return 'Started by hand'
|
||||
case 'once':
|
||||
return `Once, on ${String(schedule.at).replace('T', ' ')} (${zone})`
|
||||
case 'weekly': {
|
||||
const days = (schedule.days || []).map(cap)
|
||||
const list =
|
||||
days.length <= 1
|
||||
? days.join('')
|
||||
: `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
|
||||
return `Every ${list} at ${schedule.time} (${zone})`
|
||||
}
|
||||
case 'monthly':
|
||||
return `The ${nthLabel[String(schedule.nth)]} ${cap(schedule.weekday)} of every month at ${schedule.time} (${zone})`
|
||||
default:
|
||||
return 'No schedule'
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WEEKDAYS,
|
||||
MONTHLY_NTH,
|
||||
TIME_RE,
|
||||
AT_RE,
|
||||
MAX_OCCURRENCES,
|
||||
DAY_MS,
|
||||
wallPartsAt,
|
||||
offsetMs,
|
||||
instantsForWall,
|
||||
resolveWall,
|
||||
isRealDate,
|
||||
nthWeekdayDay,
|
||||
occurrencesBetween,
|
||||
nextOccurrence,
|
||||
describe,
|
||||
}
|
||||
@@ -12,18 +12,24 @@
|
||||
// one that decides. A spec arriving by any other route (a restore, a fixture, a
|
||||
// module shipping a definition as content) gets the same answer.
|
||||
//
|
||||
// **What Phase 1 knows, and what it deliberately refuses.** Two top-level keys
|
||||
// exist today: `schedule` and `phases`. `schedule` accepts only `{ kind:
|
||||
// 'manual' }`, because Phase 4 is what computes an occurrence from a recurrence
|
||||
// in an IANA zone and a spec that could name `weekly` before then would be a
|
||||
// schedule nothing honours. Unknown top-level keys are REFUSED rather than
|
||||
// preserved: a spec that silently carries `announcements` today is a spec whose
|
||||
// author believes announcements work, and the later phase that gives the key
|
||||
// meaning would inherit a corpus of unvalidated ones. The refusal list is the
|
||||
// changelog — Phase 4 adds the recurrence shapes, Phase 5 adds a phase's
|
||||
// **What this file knows, and what it deliberately refuses.** Two top-level keys
|
||||
// exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather
|
||||
// than preserved: a spec that silently carries `announcements` today is a spec
|
||||
// whose author believes announcements work, and the later phase that gives the
|
||||
// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
|
||||
// changelog — Phase 4 added the recurrence shapes, Phase 5 adds a phase's
|
||||
// `advance`, Phase 10 adds `announcements`.
|
||||
//
|
||||
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
|
||||
// `weekly`, `monthly` — and every check on them is a check on SHAPE. The
|
||||
// arithmetic they describe lives in `events/recurrence.js`, and the zone they are
|
||||
// computed in is `event_definitions.timezone`, a sibling column this file cannot
|
||||
// see and does not need to: a well-formed wall clock resolves in every zone (a
|
||||
// DST gap shifts it, it is never rejected), so a schedule that validates here
|
||||
// computes there.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const recurrence = require('./recurrence')
|
||||
const { checkLiteral } = require('../engagement/conditions')
|
||||
|
||||
// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the
|
||||
@@ -39,11 +45,20 @@ const MAX_PHASES = 40
|
||||
const MAX_STEPS_PER_PHASE = 100
|
||||
const MAX_STEPS = 500
|
||||
|
||||
// The schedule shapes this phase understands. Phase 4 replaces this list with
|
||||
// the four closed shapes of §E — `once`, `weekly`, `monthly`, `manual` — and
|
||||
// their timezone arithmetic. It is a list of one rather than an implicit default
|
||||
// so that the widening is a diff on this line.
|
||||
const SCHEDULE_KINDS = ['manual']
|
||||
// The four closed shapes of §E. `manual` is first because it is the default and
|
||||
// what an unscheduled draft carries; the other three are recurrences the runner
|
||||
// expands into occurrences ahead of time.
|
||||
const SCHEDULE_KINDS = ['manual', 'once', 'weekly', 'monthly']
|
||||
|
||||
// The keys each shape may carry, and the ONLY ones. A `weekly` that also names
|
||||
// an `at` is an author who believes something about it that is not true — the
|
||||
// same argument the top-level refusal makes, one level down.
|
||||
const SCHEDULE_KEYS = {
|
||||
manual: [],
|
||||
once: ['at'],
|
||||
weekly: ['days', 'time'],
|
||||
monthly: ['nth', 'weekday', 'time'],
|
||||
}
|
||||
|
||||
// What a step does when its attempts are exhausted (§L). The disposition only —
|
||||
// retry is not one of the values, it is what happens BEFORE one of them. Each
|
||||
@@ -63,6 +78,86 @@ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArr
|
||||
/** The default disposition for an action whose risk class core knows. */
|
||||
const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause'
|
||||
|
||||
/**
|
||||
* Check one schedule shape and answer the normalised form of it.
|
||||
*
|
||||
* Always answers a valid schedule — `{ kind: 'manual' }` when the input was not
|
||||
* one — because `validate` collects every error and carries on, and a caller
|
||||
* reading `spec.schedule.days` of a refused spec should find an empty recurrence
|
||||
* rather than a half-built one.
|
||||
*
|
||||
* **`days` is normalised into week order**, not into the order they were typed.
|
||||
* The spec is compared, described and diffed, and `['friday','monday']` and
|
||||
* `['monday','friday']` naming the same schedule while differing as JSON is a
|
||||
* version history that reports edits nobody made.
|
||||
*/
|
||||
function validateSchedule(kind, raw, errors) {
|
||||
const at = (key) => `spec.schedule.${key}`
|
||||
|
||||
if (kind === 'once') {
|
||||
const m = recurrence.AT_RE.exec(String(raw.at ?? ''))
|
||||
if (!m) {
|
||||
errors.push(`${at('at')}: expected a local date and time as YYYY-MM-DDTHH:MM`)
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
const [, y, mo, d, h, mi] = m.map(Number)
|
||||
// The regex admits `2026-02-30`, which is a string and not a day.
|
||||
if (!recurrence.isRealDate(y, mo, d)) {
|
||||
errors.push(`${at('at')}: "${raw.at}" is not a real date`)
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
// Stored as the operator wrote it — a wall clock in the definition's own
|
||||
// zone, never a UTC instant. §E: the schedule belongs to the event, and the
|
||||
// instant is derived at materialisation.
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return { kind: 'once', at: `${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${pad(mi)}` }
|
||||
}
|
||||
|
||||
if (kind === 'weekly' || kind === 'monthly') {
|
||||
const time = recurrence.TIME_RE.test(String(raw.time ?? '')) ? String(raw.time) : null
|
||||
if (!time) errors.push(`${at('time')}: expected a 24-hour time as HH:MM`)
|
||||
|
||||
if (kind === 'weekly') {
|
||||
const rawDays = Array.isArray(raw.days) ? raw.days : null
|
||||
if (!rawDays || rawDays.length === 0) {
|
||||
errors.push(`${at('days')}: expected a non-empty array of weekday names`)
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
const unknown = rawDays.filter((d) => !recurrence.WEEKDAYS.includes(String(d).toLowerCase()))
|
||||
if (unknown.length) {
|
||||
errors.push(
|
||||
`${at('days')}: unknown weekday(s) ${unknown.join(', ')} — expected ${recurrence.WEEKDAYS.join(', ')}`,
|
||||
)
|
||||
}
|
||||
const days = recurrence.WEEKDAYS.filter((name) =>
|
||||
rawDays.some((d) => String(d).toLowerCase() === name),
|
||||
)
|
||||
if (!time || !days.length) return { kind: 'manual' }
|
||||
return { kind: 'weekly', days, time }
|
||||
}
|
||||
|
||||
const weekday = String(raw.weekday ?? '').toLowerCase()
|
||||
if (!recurrence.WEEKDAYS.includes(weekday)) {
|
||||
errors.push(
|
||||
`${at('weekday')}: expected one of ${recurrence.WEEKDAYS.join(', ')}`,
|
||||
)
|
||||
}
|
||||
const nth = Number(raw.nth)
|
||||
if (!recurrence.MONTHLY_NTH.includes(nth)) {
|
||||
// -1 is "last", which a month with five Fridays makes different from 4.
|
||||
// There is no 5: every month has a first through fourth of every weekday,
|
||||
// so the closed set has no absent case (org lead, 2026-09-02).
|
||||
errors.push(`${at('nth')}: expected 1, 2, 3, 4 or -1 (last)`)
|
||||
}
|
||||
if (!time || !recurrence.WEEKDAYS.includes(weekday) || !recurrence.MONTHLY_NTH.includes(nth)) {
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
return { kind: 'monthly', nth, weekday, time }
|
||||
}
|
||||
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check one authored param object against an action's declared params.
|
||||
*
|
||||
@@ -136,18 +231,21 @@ function validate(raw, { knownActionIds = [] } = {}) {
|
||||
}
|
||||
|
||||
// ── schedule ──
|
||||
const rawSchedule = raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
|
||||
const rawSchedule =
|
||||
raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
|
||||
let schedule = { kind: 'manual' }
|
||||
if (!isPlainObject(rawSchedule)) {
|
||||
errors.push('spec.schedule: expected an object')
|
||||
} else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) {
|
||||
errors.push(
|
||||
`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')} (recurrence arrives in Phase 4)`,
|
||||
)
|
||||
errors.push(`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')}`)
|
||||
} else {
|
||||
const extra = Object.keys(rawSchedule).filter((k) => k !== 'kind')
|
||||
if (extra.length) errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')}`)
|
||||
schedule = { kind: rawSchedule.kind }
|
||||
const kind = rawSchedule.kind
|
||||
const allowedKeys = new Set(['kind', ...SCHEDULE_KEYS[kind]])
|
||||
const extra = Object.keys(rawSchedule).filter((k) => !allowedKeys.has(k))
|
||||
if (extra.length) {
|
||||
errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')} for kind "${kind}"`)
|
||||
}
|
||||
schedule = validateSchedule(kind, rawSchedule, errors)
|
||||
}
|
||||
|
||||
// ── phases ──
|
||||
|
||||
170
server/src/model/events/eventCalendar.model.js
Normal file
170
server/src/model/events/eventCalendar.model.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// ── The calendar ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I: "month and list view, filtered by category, scope and series",
|
||||
// and Phase 4's stated deliverable — *the thing this feature exists to replace*
|
||||
// is a WordPress calendar plugin with no series field and no recurrence.
|
||||
//
|
||||
// **A calendar entry is one of two things, and the difference is not cosmetic.**
|
||||
//
|
||||
// - A **run**: a real `event_runs` row. It has an id, a status, a health, a
|
||||
// pinned version and a console. Somebody can cancel it. It exists because the
|
||||
// runner materialised it inside its fourteen-day horizon, or because an admin
|
||||
// started it by hand.
|
||||
// - A **projection**: arithmetic. There is no row, nothing to cancel, and
|
||||
// nothing has been committed to. It exists so that a monthly event is visible
|
||||
// three weeks out instead of the calendar simply ending at the horizon (org
|
||||
// lead, 2026-09-02).
|
||||
//
|
||||
// The API says which each is and the UI renders them differently, because an
|
||||
// operator acting on a projection as though it were a booking is the failure
|
||||
// this distinction exists to prevent. A projection is a forecast of what the
|
||||
// runner *will* materialise, computed by the same `occurrencesBetween` the
|
||||
// runner itself calls — one arithmetic, so the forecast cannot disagree with
|
||||
// what later appears.
|
||||
//
|
||||
// **A projection is never emitted for an instant a run already occupies**, which
|
||||
// is what keeps the fortnight inside the horizon from showing everything twice.
|
||||
// That rule also does the right thing for a CANCELLED occurrence: the row is
|
||||
// still there, so nothing re-projects it, and an event an operator called off
|
||||
// does not reappear on the calendar as though it were still coming.
|
||||
|
||||
const runsDb = require('./eventRuns.db')
|
||||
const definitionsDb = require('./eventDefinitions.db')
|
||||
const recurrence = require('../../events/recurrence')
|
||||
|
||||
// A calendar request is operator-supplied, and a year-wide window across forty
|
||||
// weekly definitions is how a month view becomes an outage. Ninety-two days is
|
||||
// a three-month view — more than the month grid and the list either need.
|
||||
const MAX_WINDOW_DAYS = 92
|
||||
const MAX_ENTRIES = 1000
|
||||
|
||||
const runEntry = (run) => ({
|
||||
kind: 'run',
|
||||
runId: run.id,
|
||||
definitionId: run.definition_id,
|
||||
title: run.definition_title,
|
||||
slug: run.definition_slug,
|
||||
seriesId: run.series_id || null,
|
||||
seriesName: run.series_name || null,
|
||||
seriesSlug: run.series_slug || null,
|
||||
scheduledFor: run.scheduled_for,
|
||||
timezone: run.timezone,
|
||||
scope: run.scope,
|
||||
status: run.status,
|
||||
health: run.health,
|
||||
version: run.version_number,
|
||||
rehearsal: Boolean(run.rehearsal),
|
||||
waitingSteps: Number(run.waiting_steps || 0),
|
||||
})
|
||||
|
||||
const projectedEntry = (definition, occurrence) => ({
|
||||
kind: 'projected',
|
||||
runId: null,
|
||||
definitionId: definition.id,
|
||||
title: definition.title,
|
||||
slug: definition.slug,
|
||||
seriesId: definition.series_id || null,
|
||||
seriesName: definition.series_name || null,
|
||||
seriesSlug: definition.series_slug || null,
|
||||
scheduledFor: occurrence.at,
|
||||
timezone: definition.timezone,
|
||||
scope: '',
|
||||
status: null,
|
||||
health: null,
|
||||
// Why this instant is not the wall clock the schedule names. Carried on the
|
||||
// projection as well as on the materialised run, so the calendar can explain
|
||||
// a DST-shifted time before it happens rather than after.
|
||||
adjusted: occurrence.adjusted,
|
||||
shiftMinutes: occurrence.shiftMinutes,
|
||||
})
|
||||
|
||||
/**
|
||||
* The calendar for a window.
|
||||
*
|
||||
* `{ ok, window, horizon, entries }` — entries ascending by instant, runs and
|
||||
* projections interleaved. `horizon` is the instant past which nothing is
|
||||
* materialised yet, so the UI can draw the line rather than infer it.
|
||||
*
|
||||
* **The instants are UTC and the placement is the client's.** A month grid has
|
||||
* one date axis and the viewer's own zone is what "this month" means to the
|
||||
* person reading it; each entry carries its own `timezone` so the time beside it
|
||||
* reads `20:00 Europe/Berlin` and nobody misreads a shard's local schedule as
|
||||
* their own. That is the split §E's "the timezone belongs to the event" implies:
|
||||
* the event owns the time, the reader owns the calendar.
|
||||
*/
|
||||
async function calendar({
|
||||
from,
|
||||
to,
|
||||
status = null,
|
||||
scope = null,
|
||||
seriesId = null,
|
||||
horizonDays = 14,
|
||||
now = new Date(),
|
||||
} = {}) {
|
||||
const start = from instanceof Date ? from : new Date(from)
|
||||
const end = to instanceof Date ? to : new Date(to)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return { ok: false, status: 400, errors: ['from and to must be dates'] }
|
||||
}
|
||||
if (end <= start) {
|
||||
return { ok: false, status: 400, errors: ['to must be after from'] }
|
||||
}
|
||||
if (end - start > MAX_WINDOW_DAYS * recurrence.DAY_MS) {
|
||||
return { ok: false, status: 400, errors: [`the window may span at most ${MAX_WINDOW_DAYS} days`] }
|
||||
}
|
||||
|
||||
const runs = await runsDb.listInWindow({ from: start, to: end, status, scope, seriesId })
|
||||
const entries = runs.map(runEntry)
|
||||
|
||||
// Every instant a run already occupies, keyed by definition. Projections are
|
||||
// per definition at the empty scope, so the definition and the instant are the
|
||||
// whole key -- the same triple the unique index uses, with the scope fixed.
|
||||
const taken = new Set(
|
||||
runs
|
||||
.filter((r) => !r.scope)
|
||||
.map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
|
||||
)
|
||||
|
||||
// A status filter is a filter on RUNS. A projection has no status, so asking
|
||||
// for "everything that failed" must not answer with a forecast — it would be a
|
||||
// forecast that failed, which is not a thing.
|
||||
// The scope filter behaves the same way, and for the same reason: automatic
|
||||
// expansion is at the empty scope (org lead, 2026-09-02), so a request narrowed
|
||||
// to a named scope has no forecast to give.
|
||||
if (!status && !scope) {
|
||||
const definitions = await definitionsDb.findSchedulable()
|
||||
for (const definition of definitions) {
|
||||
if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
|
||||
const schedule = definition.version_spec?.schedule
|
||||
if (!schedule || schedule.kind === 'manual') continue
|
||||
let occurrences = []
|
||||
try {
|
||||
occurrences = recurrence.occurrencesBetween(
|
||||
schedule,
|
||||
definition.timezone || 'UTC',
|
||||
start,
|
||||
end,
|
||||
)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const occurrence of occurrences) {
|
||||
if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
|
||||
entries.push(projectedEntry(definition, occurrence))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
window: { from: start, to: end },
|
||||
horizon: new Date(now.getTime() + horizonDays * recurrence.DAY_MS),
|
||||
entries: entries.slice(0, MAX_ENTRIES),
|
||||
truncated: entries.length > MAX_ENTRIES,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { calendar, MAX_WINDOW_DAYS, MAX_ENTRIES }
|
||||
@@ -114,6 +114,39 @@ const markReady = (id, versionId, userId) =>
|
||||
[versionId, userId, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Every definition the runner should expand a recurrence for (Phase 4).
|
||||
*
|
||||
* `ready` is the whole gate, and it is deliberately the only one: EVENTS.md §E
|
||||
* defines `ready` as "a version has been published and the schedule is live", so
|
||||
* publishing IS the switch and archiving is how an operator turns a recurrence
|
||||
* off. A separate schedule-enabled flag would be a second answer to a question
|
||||
* `state` already answers, and the two would eventually disagree.
|
||||
*
|
||||
* The VERSION's spec is joined rather than the definition's working copy: the
|
||||
* draft is what an author is midway through editing, and a half-typed `weekly`
|
||||
* must never materialise anything. The pinned spec comes back with it, so the
|
||||
* whole expansion is one round trip.
|
||||
*
|
||||
* The series columns are here for the CALENDAR rather than the runner, which
|
||||
* ignores them: a projected occurrence has to be filterable and labellable by
|
||||
* its arc exactly as a materialised run is, and a second query to learn the name
|
||||
* of a row this one already reached would be two round trips for a join.
|
||||
*/
|
||||
const findSchedulable = async () => {
|
||||
const rows = await query(
|
||||
`SELECT d.id, d.title, d.slug, d.timezone, d.grace_seconds, d.concurrency_key,
|
||||
d.current_version_id, d.series_id, v.spec AS version_spec,
|
||||
s.name AS series_name, s.slug AS series_slug
|
||||
FROM event_definitions d
|
||||
JOIN event_versions v ON v.id = d.current_version_id
|
||||
LEFT JOIN event_series s ON s.id = d.series_id
|
||||
WHERE d.state = 'ready'
|
||||
ORDER BY d.id`,
|
||||
)
|
||||
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive. Never a hard delete while runs reference it (§ API surface) — and the
|
||||
* schema would refuse one anyway, because `event_runs.version_id` RESTRICTs.
|
||||
@@ -127,6 +160,7 @@ module.exports = {
|
||||
getById,
|
||||
getBySlug,
|
||||
slugTaken,
|
||||
findSchedulable,
|
||||
insert,
|
||||
update,
|
||||
markReady,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -50,9 +50,21 @@ function renderConcurrencyKey(template, params) {
|
||||
*
|
||||
* `scheduledFor` defaults to now — "start now" is an occurrence whose instant is
|
||||
* the present, not a separate concept, which is what keeps the runner's one
|
||||
* materialise/advance path honest when Phase 4 adds recurrence on top.
|
||||
* materialise/advance path honest now that Phase 4 has put recurrence on top.
|
||||
*
|
||||
* **Phase 4's expansion calls this, rather than a second insert path beside it.**
|
||||
* That is deliberate: every check here — the definition is still `ready`, the
|
||||
* version still has phases, the concurrency key renders, the first phase's steps
|
||||
* are materialised with their idempotency keys — is one a scheduled occurrence
|
||||
* needs at least as much as a hand-started one, because there is nobody watching
|
||||
* when it happens. The `INSERT IGNORE` answering `created: false` is what makes
|
||||
* it safe to call on every tick for every occurrence inside the horizon.
|
||||
*/
|
||||
async function create(definitionId, { scope = '', scheduledFor = null, rehearsal = false, params = null } = {}, userId) {
|
||||
async function create(
|
||||
definitionId,
|
||||
{ scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {},
|
||||
userId,
|
||||
) {
|
||||
const definition = await definitionsDb.getById(definitionId)
|
||||
if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (definition.state !== 'ready') {
|
||||
@@ -107,6 +119,11 @@ async function create(definitionId, { scope = '', scheduledFor = null, rehearsal
|
||||
version: version.version,
|
||||
scope: scopeValue,
|
||||
rehearsal: Boolean(rehearsal),
|
||||
// 'manual' is an admin pressing start; 'schedule' is the runner expanding
|
||||
// a recurrence (Phase 4). Both produce the same row, and the log is the
|
||||
// only place the difference is recorded — `started_by` is NULL for both a
|
||||
// scheduled occurrence and one started by a since-deleted account.
|
||||
source,
|
||||
by: userId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
// ── event_series — SQL only ────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D. The arc a definition may belong to. Phase 1 needs the reads —
|
||||
// `event_definitions.series_id` is a foreign key and the definition save path
|
||||
// has to check it resolves — and creating one is Phase 4's, where the calendar
|
||||
// is what makes an arc visible.
|
||||
// EVENTS.md §D. The arc a definition may belong to. Phase 1 needed only the
|
||||
// reads — `event_definitions.series_id` is a foreign key and the definition save
|
||||
// path has to check it resolves — and Phase 4 adds the writes, because the
|
||||
// calendar is what makes an arc visible and a form cannot offer a value nobody
|
||||
// can create.
|
||||
//
|
||||
// `ordering` here places a SERIES among the others on the calendar. A
|
||||
// definition's place WITHIN its arc is `event_definitions.series_order`, which
|
||||
// is the column an editor drags; the two are deliberately different columns on
|
||||
// different tables and the schema comment says so.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const list = async () =>
|
||||
query('SELECT * FROM event_series ORDER BY ordering, name, id')
|
||||
// `definition_count` is a correlated subquery rather than a join with a GROUP BY:
|
||||
// the list is a handful of rows, and the delete path needs the same number to
|
||||
// tell an operator what they are about to detach.
|
||||
const SELECT_LIST = `
|
||||
SELECT s.*,
|
||||
(SELECT COUNT(*) FROM event_definitions d WHERE d.series_id = s.id) AS definition_count
|
||||
FROM event_series s
|
||||
`
|
||||
|
||||
const list = async () => query(`${SELECT_LIST} ORDER BY s.ordering, s.name, s.id`)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM event_series WHERE id = ?', [id])
|
||||
const [row] = await query(`${SELECT_LIST} WHERE s.id = ?`, [id])
|
||||
return row || null
|
||||
}
|
||||
|
||||
@@ -20,4 +34,40 @@ const exists = async (id) => {
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
module.exports = { list, getById, exists }
|
||||
/** Does any OTHER series hold this slug? The uniqueness pre-check. */
|
||||
const slugTaken = async (slug, exceptId = null) => {
|
||||
const rows = exceptId
|
||||
? await query('SELECT id FROM event_series WHERE slug = ? AND id <> ?', [slug, exceptId])
|
||||
: await query('SELECT id FROM event_series WHERE slug = ?', [slug])
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const insert = async (s) => {
|
||||
const result = await query(
|
||||
`INSERT INTO event_series (name, slug, description, ordering, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[s.name, s.slug, s.description, s.ordering, s.created_by],
|
||||
)
|
||||
return Number(result.insertId)
|
||||
}
|
||||
|
||||
const update = (id, s) =>
|
||||
query(
|
||||
`UPDATE event_series SET name = ?, slug = ?, description = ?, ordering = ? WHERE id = ?`,
|
||||
[s.name, s.slug, s.description, s.ordering, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* A hard delete, and the one place in this feature that is one.
|
||||
*
|
||||
* A series is a label rather than authored content: nothing pins one, no run
|
||||
* references one, and `event_definitions.series_id` is `ON DELETE SET NULL`, so
|
||||
* removing a series detaches its definitions and destroys nothing. That is why
|
||||
* it is not archived the way a definition is — an archived label would be a
|
||||
* state every calendar query has to remember for no benefit. The model answers
|
||||
* with how many definitions were detached, so the operator learns what happened
|
||||
* rather than discovering it on the calendar.
|
||||
*/
|
||||
const remove = (id) => query('DELETE FROM event_series WHERE id = ?', [id])
|
||||
|
||||
module.exports = { list, getById, exists, slugTaken, insert, update, remove }
|
||||
|
||||
93
server/src/model/events/eventSeries.model.js
Normal file
93
server/src/model/events/eventSeries.model.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// ── Event series — the arc ─────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §I. "Royal Spy Mission → Risky Partner → Message From the
|
||||
// Void" is continuity that exists nowhere in the tooling this feature replaces
|
||||
// (§ "What the real calendar shows, and what it is missing": *no series or
|
||||
// recurrence field*). One small table buys it, and this is the policy half.
|
||||
//
|
||||
// **Why the writes are `admin, editor` and not `admin`.** A series is authoring,
|
||||
// and it is the same act as writing the definition that goes in it — §N2's
|
||||
// narrow gate is about *committing the deployment to a run* (publish, start),
|
||||
// which naming an arc does not do. An editor who can write the events but not
|
||||
// the arc they belong to would have to ask an admin to type a title.
|
||||
//
|
||||
// **A slug is derived once and then frozen**, exactly as a definition's is: the
|
||||
// public arc page lives at `/events/series/:slug` (Phase 14), and a slug that
|
||||
// moved would break every link to it. Renaming the series is free.
|
||||
|
||||
const db = require('./eventSeries.db')
|
||||
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
||||
|
||||
const MAX_NAME = 160
|
||||
const MAX_DESCRIPTION = 2000
|
||||
|
||||
const trimOrNull = (v, max) => {
|
||||
if (v === undefined || v === null) return null
|
||||
const s = String(v).trim()
|
||||
return s === '' ? null : s.slice(0, max)
|
||||
}
|
||||
|
||||
const list = () => db.list()
|
||||
|
||||
const getById = (id) => db.getById(id)
|
||||
|
||||
async function validate(input, { existing = null } = {}) {
|
||||
const errors = []
|
||||
const body = input && typeof input === 'object' ? input : {}
|
||||
|
||||
const name = trimOrNull(body.name, MAX_NAME)
|
||||
if (!name) errors.push('name is required')
|
||||
|
||||
const description = trimOrNull(body.description, MAX_DESCRIPTION)
|
||||
|
||||
const orderingRaw = body.ordering === undefined ? (existing?.ordering ?? 0) : body.ordering
|
||||
const ordering = Number(orderingRaw)
|
||||
if (!Number.isInteger(ordering) || ordering < 0 || ordering > 9999) {
|
||||
errors.push('ordering must be an integer 0..9999')
|
||||
}
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
return { ok: true, series: { name, description, ordering } }
|
||||
}
|
||||
|
||||
async function create(input, userId) {
|
||||
const checked = await validate(input)
|
||||
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
|
||||
|
||||
// The taken set is read here rather than inside `uniqueSlug` because that
|
||||
// helper is pure — the same shape the team and definition paths use.
|
||||
const taken = (await db.list()).map((s) => s.slug)
|
||||
const slug = uniqueSlug(checked.series.name, taken, { fallback: 'series' })
|
||||
|
||||
const id = await db.insert({ ...checked.series, slug, created_by: userId || null })
|
||||
return { ok: true, status: 201, series: await db.getById(id) }
|
||||
}
|
||||
|
||||
async function update(id, input, userId) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such series'] }
|
||||
|
||||
const checked = await validate(input, { existing })
|
||||
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
|
||||
|
||||
// The slug is the existing one, deliberately: renaming a series must not move
|
||||
// the address its arc page lives at.
|
||||
await db.update(id, { ...checked.series, slug: existing.slug })
|
||||
return { ok: true, status: 200, series: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a series, detaching whatever belonged to it.
|
||||
*
|
||||
* The count comes back so the caller can say *"3 events were detached"* rather
|
||||
* than leaving an operator to notice on the calendar. `series_id` is
|
||||
* `ON DELETE SET NULL`, so nothing is destroyed and re-attaching is a dropdown.
|
||||
*/
|
||||
async function remove(id) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such series'] }
|
||||
await db.remove(id)
|
||||
return { ok: true, status: 200, detached: Number(existing.definition_count || 0) }
|
||||
}
|
||||
|
||||
module.exports = { list, getById, validate, create, update, remove, slugify, MAX_NAME }
|
||||
@@ -23,6 +23,9 @@ const definitionsDb = require('../../../model/events/eventDefinitions.db')
|
||||
const definitions = require('../../../model/events/eventDefinitions.model')
|
||||
const versionsDb = require('../../../model/events/eventVersions.db')
|
||||
const seriesDb = require('../../../model/events/eventSeries.db')
|
||||
const series = require('../../../model/events/eventSeries.model')
|
||||
const calendarModel = require('../../../model/events/eventCalendar.model')
|
||||
const eventRunner = require('../../../utils/eventRunner')
|
||||
const runsDb = require('../../../model/events/eventRuns.db')
|
||||
const runs = require('../../../model/events/eventRuns.model')
|
||||
const controls = require('../../../model/events/eventRunControls.model')
|
||||
@@ -152,17 +155,83 @@ exports.catalog = (_req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
const shapeSeries = (s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
description: s.description,
|
||||
ordering: s.ordering,
|
||||
definitionCount: Number(s.definition_count || 0),
|
||||
})
|
||||
|
||||
/** GET /api/v1/admin/events/series */
|
||||
exports.listSeries = async (_req, res) => {
|
||||
const rows = await seriesDb.list()
|
||||
res.json({ series: rows.map(shapeSeries) })
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/events/series */
|
||||
exports.createSeries = async (req, res) => {
|
||||
const result = await series.create(req.body, req.user?.id)
|
||||
if (!result.ok) return res.status(result.status).json({ errors: result.errors })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.series.created',
|
||||
detail: { id: result.series.id, name: result.series.name },
|
||||
})
|
||||
res.status(201).json({ series: shapeSeries(result.series) })
|
||||
}
|
||||
|
||||
/** PUT /api/v1/admin/events/series/:seriesId */
|
||||
exports.updateSeries = async (req, res) => {
|
||||
const id = asId(req.params.seriesId)
|
||||
if (!id) return res.status(404).json({ error: 'no such series' })
|
||||
const result = await series.update(id, req.body, req.user?.id)
|
||||
if (!result.ok) return res.status(result.status).json({ errors: result.errors })
|
||||
await activity.log({ req, action: 'event.series.updated', detail: { id, name: result.series.name } })
|
||||
res.json({ series: shapeSeries(result.series) })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/events/series/:seriesId
|
||||
*
|
||||
* `detached` is in the response because the delete is not confined to the row:
|
||||
* `series_id` is `ON DELETE SET NULL`, so definitions that belonged to the arc
|
||||
* survive it without one. Saying how many is the difference between an operator
|
||||
* knowing and an operator finding out.
|
||||
*/
|
||||
exports.deleteSeries = async (req, res) => {
|
||||
const id = asId(req.params.seriesId)
|
||||
if (!id) return res.status(404).json({ error: 'no such series' })
|
||||
const result = await series.remove(id)
|
||||
if (!result.ok) return res.status(result.status).json({ errors: result.errors })
|
||||
await activity.log({ req, action: 'event.series.deleted', detail: { id, detached: result.detached } })
|
||||
res.json({ ok: true, detached: result.detached })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/events/calendar
|
||||
*
|
||||
* `from` and `to` are UTC instants and the caller supplies both: a month grid
|
||||
* knows its own boundaries in the viewer's zone, and having the server guess
|
||||
* them would be the server guessing the viewer's zone.
|
||||
*/
|
||||
exports.calendar = async (req, res) => {
|
||||
const result = await calendarModel.calendar({
|
||||
from: req.query.from,
|
||||
to: req.query.to,
|
||||
status: req.query.status || null,
|
||||
scope: req.query.scope || null,
|
||||
seriesId: asId(req.query.seriesId),
|
||||
horizonDays: eventRunner.HORIZON_DAYS,
|
||||
})
|
||||
if (!result.ok) return res.status(result.status).json({ errors: result.errors })
|
||||
res.json({
|
||||
series: rows.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
description: s.description,
|
||||
ordering: s.ordering,
|
||||
})),
|
||||
window: result.window,
|
||||
horizon: result.horizon,
|
||||
horizonDays: eventRunner.HORIZON_DAYS,
|
||||
entries: result.entries,
|
||||
truncated: result.truncated,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -272,12 +341,16 @@ exports.publish = async (req, res) => {
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.published',
|
||||
detail: { id, version: result.version, versionId: result.versionId },
|
||||
detail: { id, version: result.version, versionId: result.versionId, repinned: result.repinned },
|
||||
})
|
||||
return res.json({
|
||||
event: shapeDefinition(result.definition),
|
||||
version: result.version,
|
||||
versionId: result.versionId,
|
||||
// How many already-materialised occurrences moved to this version. The
|
||||
// screen says so, because "my fix did not reach next Friday" is otherwise
|
||||
// found out on Friday.
|
||||
repinned: result.repinned,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,13 @@
|
||||
// stubbed — there is no advance condition until Phase 5, no resource ledger
|
||||
// until Phase 8 and no caps to price against until Phase 6.
|
||||
//
|
||||
// **Literal paths are declared before `/:id`**, so `/catalog`, `/series` and
|
||||
// `/runs` are never read as an event id.
|
||||
// **Literal paths are declared before `/:id`**, so `/catalog`, `/series`,
|
||||
// `/calendar` and `/runs` are never read as an event id.
|
||||
//
|
||||
// **Phase 4 added the series writes and the calendar.** The series writes are
|
||||
// `admin, editor` rather than `admin`: naming an arc is authoring, and §N2's
|
||||
// narrow gate is about committing the deployment to a run. The calendar is a
|
||||
// staff read like every other read here.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
@@ -51,13 +56,74 @@ eventsRouter.get(
|
||||
'/series',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'List the event series a definition may belong to'
|
||||
// #swagger.description = 'A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.'
|
||||
// #swagger.description = 'A series is the arc several definitions form together - Royal Spy Mission then Risky Partner then Message From the Void - which is continuity the tooling this feature replaces has no field for at all. definitionCount is how many definitions currently belong to each.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "array", items: { type: "object", properties: { id: { type: "integer" }, name: { type: "string" }, slug: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } } } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.listSeries,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/series',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Create an event series'
|
||||
// #swagger.description = 'Admin or editor, not admin alone: naming an arc is authoring, and the narrow gate of section N2 is about committing the deployment to a run (publish, start), which this does not. The slug is derived from the name once and then frozen, because the public arc page lives at it; renaming the series afterwards is free. ordering places this series among the others on the calendar, and is not a position within it - a definition place in its arc is its own seriesOrder.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } }, required: ["name"] } } } } */
|
||||
/* #swagger.responses[201] = { description: 'The created series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation failed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.createSeries,
|
||||
)
|
||||
|
||||
eventsRouter.put(
|
||||
'/series/:seriesId',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Rename or reorder an event series'
|
||||
// #swagger.description = 'The slug is deliberately not editable: it is the address the arc page lives at, and a slug that moved would break every link to it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } }, required: ["name"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The updated series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation failed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such series', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.updateSeries,
|
||||
)
|
||||
|
||||
eventsRouter.delete(
|
||||
'/series/:seriesId',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Delete an event series, detaching whatever belonged to it'
|
||||
// #swagger.description = 'A hard delete, and the only one in this feature - a definition is archived instead. A series is a label rather than authored content: nothing pins one, no run references one, and event_definitions.series_id is ON DELETE SET NULL, so its definitions survive without an arc and re-attaching is a dropdown. The response says how many were detached.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Deleted; detached is how many definitions lost their series', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, detached: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such series', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.deleteSeries,
|
||||
)
|
||||
|
||||
// ── The calendar ────────────────────────────────────────────────────
|
||||
|
||||
eventsRouter.get(
|
||||
'/calendar',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'The calendar for a window: materialised runs and projected occurrences'
|
||||
// #swagger.description = 'Staff, like every other read here. Each entry is one of two kinds and the difference matters: a run entry is a real row with a status, a pinned version and a console, and somebody can cancel it; a projected entry is arithmetic - no row, nothing committed, nothing to cancel. Runs exist inside the runner materialisation horizon (14 days by default, horizonDays in the response); beyond it the same recurrence arithmetic forecasts what will be materialised, so a monthly event is still visible three weeks out. A projection is never emitted for an instant a run already occupies, which is also why a cancelled occurrence does not reappear as a forecast. Instants are UTC and each entry carries the event own IANA zone: the event owns the time, the reader owns the calendar. Filtering by status or by a named scope suppresses projections, because a forecast has no status and automatic expansion happens at the empty scope.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['from'] = { in: 'query', description: 'Window start, a UTC instant', required: true, schema: { type: 'string' } }
|
||||
// #swagger.parameters['to'] = { in: 'query', description: 'Window end, a UTC instant. At most 92 days after from', required: true, schema: { type: 'string' } }
|
||||
// #swagger.parameters['status'] = { in: 'query', description: 'Only runs in this status; suppresses projections', required: false, schema: { type: 'string' } }
|
||||
// #swagger.parameters['scope'] = { in: 'query', description: 'Only runs at this scope; suppresses projections', required: false, schema: { type: 'string' } }
|
||||
// #swagger.parameters['seriesId'] = { in: 'query', description: 'Only events belonging to this series', required: false, schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'The window', content: { "application/json": { schema: { type: "object", properties: { window: { type: "object", additionalProperties: true }, horizon: { type: "string" }, horizonDays: { type: "integer" }, truncated: { type: "boolean" }, entries: { type: "array", items: { type: "object", properties: { kind: { type: "string" }, runId: { type: "integer", nullable: true }, definitionId: { type: "integer" }, title: { type: "string" }, slug: { type: "string" }, seriesName: { type: "string", nullable: true }, scheduledFor: { type: "string" }, timezone: { type: "string" }, scope: { type: "string" }, status: { type: "string", nullable: true }, health: { type: "string", nullable: true }, adjusted: { type: "string", nullable: true } } } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The window is missing, inverted or wider than 92 days', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.calendar,
|
||||
)
|
||||
|
||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Declared ahead of /:id so the literal path is never read as a definition id.
|
||||
@@ -265,9 +331,9 @@ eventsRouter.post(
|
||||
'/:id/publish',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Snapshot the working spec into an immutable version and mark the definition ready'
|
||||
// #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.'
|
||||
// #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The definition, now ready, and the version that was cut', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The spec is invalid, or no phase has any steps', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'A step names an action no module registers, or the definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
|
||||
@@ -12,14 +12,22 @@
|
||||
// 3. **advance** — claim each due run and move it through its phases
|
||||
// 4. **prune** — the `event_run_log` retention sweep, on its own long clock
|
||||
//
|
||||
// **What "materialise" means in this phase.** §E's tick materialises due
|
||||
// occurrences from a recurrence; the spec validator accepts `kind: 'manual'`
|
||||
// alone until Phase 4, so there is no recurrence to expand and the only
|
||||
// occurrences that exist are the ones an admin created. What that leaves for this
|
||||
// leg is the half that is already real and already needed: the grace window. A
|
||||
// run whose instant passed while the process was down does not start late and
|
||||
// silently — it becomes `missed`, which is terminal and which a human can see
|
||||
// (§L). Phase 4 adds the expansion above it.
|
||||
// **What "materialise" means, and why it is two halves.** Phase 4 completed it.
|
||||
// The first half EXPANDS: every `ready` definition's recurrence is computed in
|
||||
// its own IANA zone and every occurrence inside a fourteen-day horizon becomes a
|
||||
// real `scheduled` row (`INSERT IGNORE` against the occurrence key, so the tick
|
||||
// that already made one makes nothing). The second half SWEEPS: a run whose
|
||||
// instant passed while the process was down does not start late and silently —
|
||||
// it becomes `missed`, which is terminal and which a human can see (§L).
|
||||
//
|
||||
// **The two halves need each other, and the horizon is why.** Expansion looks
|
||||
// forward from `now - grace` only, so an occurrence nobody ever materialised is
|
||||
// never invented retroactively — waking up after three days down must not
|
||||
// manufacture three days of history that no operator could have seen or
|
||||
// cancelled. It does not have to: because rows exist a fortnight ahead of their
|
||||
// instant, an outage that spans an occurrence finds the row already there, and
|
||||
// the sweep marks it `missed` honestly. The horizon is what makes the missed
|
||||
// sweep mean anything for a recurring event.
|
||||
//
|
||||
// **Two properties this file must not lose**, both already paid for once on this
|
||||
// codebase:
|
||||
@@ -45,6 +53,9 @@ const runsDb = require('../model/events/eventRuns.db')
|
||||
const stepsDb = require('../model/events/eventRunSteps.db')
|
||||
const logDb = require('../model/events/eventRunLog.db')
|
||||
const versionsDb = require('../model/events/eventVersions.db')
|
||||
const definitionsDb = require('../model/events/eventDefinitions.db')
|
||||
const runsModel = require('../model/events/eventRuns.model')
|
||||
const recurrence = require('../events/recurrence')
|
||||
const registries = require('../modules/registries')
|
||||
const { dispatchStep } = require('../events/dispatch')
|
||||
const log = require('./logger')('event-runner')
|
||||
@@ -59,6 +70,19 @@ const POLL_MS = Number(process.env.EVENT_POLL_MS) || 15_000
|
||||
const RUN_BATCH = Number(process.env.EVENT_RUN_BATCH) || 50
|
||||
const STEPS_PER_TICK = Number(process.env.EVENT_STEPS_PER_TICK) || 25
|
||||
|
||||
// How far ahead a recurrence is turned into real rows (org lead, 2026-09-02).
|
||||
// Fourteen days is a fortnight of occurrences an operator can see, cancel and
|
||||
// reschedule ONE AT A TIME, which a projection is not — and it is short enough
|
||||
// that a definition edited today affects almost everything still ahead of it.
|
||||
// Beyond it the calendar projects rather than materialises, so a monthly event
|
||||
// is still visible three weeks out without a row nobody will honour.
|
||||
const HORIZON_DAYS = Number(process.env.EVENT_MATERIALISE_AHEAD_DAYS) || 14
|
||||
|
||||
// A bound on one definition's expansion in one tick, not a target. A daily
|
||||
// schedule over a fortnight is fourteen; this is what stops a hand-written spec
|
||||
// turning one tick into a thousand inserts.
|
||||
const MAX_OCCURRENCES_PER_DEFINITION = 100
|
||||
|
||||
// A step's retries (org lead, 2026-09-02). §L specifies `retry(n) -> skip |
|
||||
// pause | abort_run` and names no `n`; it lives here, as one number an operator
|
||||
// can change, rather than in a column no authoring surface would ever show.
|
||||
@@ -397,6 +421,98 @@ async function processRun(run, now = new Date()) {
|
||||
}
|
||||
|
||||
/** Occurrences that passed their own grace window while nothing was running (§L). */
|
||||
/**
|
||||
* Turn every `ready` definition's recurrence into rows inside the horizon.
|
||||
*
|
||||
* The first half of the materialise leg (§E). Answers how many occurrences were
|
||||
* newly created, which is zero on almost every tick — the horizon moves fifteen
|
||||
* seconds at a time, so a weekly event creates one row a week and answers
|
||||
* `created: false` for the same fourteen occurrences in between.
|
||||
*
|
||||
* **The window starts at `now - grace`, not at `now`.** An occurrence whose
|
||||
* instant just passed is still startable inside the definition's own grace
|
||||
* window, and that is exactly the case of a definition published four minutes
|
||||
* before its first occurrence. An occurrence older than that is not materialised
|
||||
* at all rather than materialised-then-swept: a row nobody could ever have seen
|
||||
* is not history, and writing one would put a `missed` event on the calendar for
|
||||
* a date on which this deployment had no such event.
|
||||
*
|
||||
* **Expansion is per definition and one failure does not stop the sweep.** A
|
||||
* spec written directly into the database with a shape the validator would have
|
||||
* refused is a bad row, not a bad tick.
|
||||
*/
|
||||
async function expandSchedules(now) {
|
||||
const definitions = await definitionsDb.findSchedulable()
|
||||
let created = 0
|
||||
|
||||
for (const definition of definitions) {
|
||||
const schedule = definition.version_spec?.schedule
|
||||
if (!schedule || schedule.kind === 'manual') continue
|
||||
|
||||
const from = now.getTime() - Number(definition.grace_seconds || 0) * 1000
|
||||
const to = now.getTime() + HORIZON_DAYS * recurrence.DAY_MS
|
||||
|
||||
let occurrences
|
||||
try {
|
||||
occurrences = recurrence.occurrencesBetween(schedule, definition.timezone || 'UTC', from, to, {
|
||||
limit: MAX_OCCURRENCES_PER_DEFINITION,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('could not expand a schedule', {
|
||||
definition: definition.id,
|
||||
timezone: definition.timezone,
|
||||
message: err.message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
for (const occurrence of occurrences) {
|
||||
try {
|
||||
// `runsModel.create` rather than a second insert path: it re-checks that
|
||||
// the definition is still `ready` and the version still has phases, and
|
||||
// it materialises the first phase's steps with their idempotency keys.
|
||||
// Nobody is watching a scheduled occurrence, so it needs those checks
|
||||
// more than a hand-started one does.
|
||||
const result = await runsModel.create(
|
||||
definition.id,
|
||||
// Scope is empty, deliberately (org lead, 2026-09-02). A fan-out across
|
||||
// named scopes needs a registry of what a scope IS, which no phase owns
|
||||
// yet; inventing one here would be a contract the modules were never
|
||||
// asked about. An admin's own start route still takes any scope.
|
||||
{ scope: '', scheduledFor: occurrence.at, source: 'schedule' },
|
||||
null,
|
||||
)
|
||||
if (!result.ok || !result.created) continue
|
||||
created += 1
|
||||
if (occurrence.adjusted) {
|
||||
// Why the clock reads oddly, recorded where an operator will look for
|
||||
// it rather than left to be rediscovered at 3am on the last Sunday in
|
||||
// October.
|
||||
await logDb.write({
|
||||
runId: result.run.id,
|
||||
kind: 'run.created',
|
||||
detail: {
|
||||
dstAdjusted: occurrence.adjusted,
|
||||
shiftMinutes: occurrence.shiftMinutes,
|
||||
timezone: definition.timezone,
|
||||
scheduledFor: occurrence.at,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('could not materialise an occurrence', {
|
||||
definition: definition.id,
|
||||
at: occurrence.at,
|
||||
message: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (created) log.info('occurrences materialised', { created, horizonDays: HORIZON_DAYS })
|
||||
return created
|
||||
}
|
||||
|
||||
async function sweepMissed(now) {
|
||||
const missed = await runsDb.findMissed(now)
|
||||
let n = 0
|
||||
@@ -433,6 +549,12 @@ async function tick(now = new Date()) {
|
||||
log.error('failed to reclaim stale claims', { message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
await expandSchedules(now)
|
||||
} catch (err) {
|
||||
log.error('schedule expansion failed', { message: err.message })
|
||||
}
|
||||
|
||||
try {
|
||||
await sweepMissed(now)
|
||||
} catch (err) {
|
||||
@@ -507,11 +629,13 @@ module.exports = {
|
||||
advanceRun,
|
||||
drainStep,
|
||||
sweepMissed,
|
||||
expandSchedules,
|
||||
prune,
|
||||
OWNER,
|
||||
POLL_MS,
|
||||
MAX_ATTEMPTS,
|
||||
RETRY_MS,
|
||||
HORIZON_DAYS,
|
||||
RUN_LEASE_MS,
|
||||
LOG_RETENTION_DAYS,
|
||||
RUN_TERMINAL,
|
||||
|
||||
Reference in New Issue
Block a user