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 ──
|
||||
|
||||
Reference in New Issue
Block a user