T-HUB/packages/scheduling/src/cron.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

249 lines
8.9 KiB
TypeScript

import { ImpossibleScheduleError, InvalidCronError } from "./errors.js";
/**
* A single field of a five-field cron expression after alias resolution.
*
* `values` is the expanded set of allowed integers; `restricted` is false only
* for the literal `*`, which matters for the cron dom/dow OR rule: when both
* fields are restricted the day matches if EITHER matches, and when only one is
* restricted it alone constrains the day.
*/
export interface CronField {
readonly values: ReadonlySet<number>;
readonly restricted: boolean;
}
/**
* Parsed five-field cron expression: minute hour day-of-month month
* day-of-week. minute/hour/dow are zero-based; month is 1-12, dom 1-31.
*/
export interface ParsedCron {
readonly minute: CronField;
readonly hour: CronField;
readonly dayOfMonth: CronField;
readonly month: CronField;
readonly dayOfWeek: CronField;
}
interface FieldBounds {
readonly min: number;
readonly max: number;
/** alias -> value, applied before range parsing (MON, JAN, ...) */
readonly aliases?: Readonly<Record<string, number>>;
}
const MONTH_ALIASES: Readonly<Record<string, number>> = {
JAN: 1,
FEB: 2,
MAR: 3,
APR: 4,
MAY: 5,
JUN: 6,
JUL: 7,
AUG: 8,
SEP: 9,
OCT: 10,
NOV: 11,
DEC: 12,
};
const DOW_ALIASES: Readonly<Record<string, number>> = {
SUN: 0,
MON: 1,
TUE: 2,
WED: 3,
THU: 4,
FRI: 5,
SAT: 6,
};
const MINUTE: FieldBounds = { min: 0, max: 59 };
const HOUR: FieldBounds = { min: 0, max: 23 };
const DOM: FieldBounds = { min: 1, max: 31 };
const MONTH: FieldBounds = { min: 1, max: 12, aliases: MONTH_ALIASES };
const DOW: FieldBounds = { min: 0, max: 6, aliases: DOW_ALIASES };
export const FIELD_NAMES = ["minute", "hour", "day-of-month", "month", "day-of-week"] as const;
/** Month lengths ignoring leap years (February counted as 29 to stay permissive). */
const MONTH_LENGTHS: readonly number[] = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
function applyAliases(token: string, bounds: FieldBounds): string {
const aliases = bounds.aliases;
if (!aliases) return token;
const mapped = aliases[token.toUpperCase()];
return mapped === undefined ? token : String(mapped);
}
function parseValue(token: string, bounds: FieldBounds, fieldName: string): number {
const mapped = applyAliases(token, bounds);
if (!/^\d+$/.test(mapped)) {
throw new InvalidCronError(`invalid value "${token}" in ${fieldName} field`);
}
const value = Number(mapped);
if (value < bounds.min || value > bounds.max) {
throw new InvalidCronError(
`value ${value} out of range ${bounds.min}-${bounds.max} in ${fieldName} field`,
);
}
return value;
}
function parseStep(token: string, fieldName: string): number {
const trimmed = token.trim();
if (!/^\d+$/.test(trimmed)) {
throw new InvalidCronError(`invalid step "${token}" in ${fieldName} field`);
}
const step = Number(trimmed);
if (!Number.isInteger(step) || step <= 0) {
throw new InvalidCronError(`step must be a positive integer, got "${token}" in ${fieldName} field`);
}
return step;
}
function parseField(field: string, bounds: FieldBounds, fieldName: string): CronField {
const raw = field.trim();
if (raw === "") {
throw new InvalidCronError(`empty ${fieldName} field`);
}
const values = new Set<number>();
for (const part of raw.split(",")) {
const token = part.trim();
if (token === "") {
throw new InvalidCronError(`empty list element in ${fieldName} field "${field}"`);
}
// Split off an optional /step suffix, being careful not to break on a bare value.
const slash = token.indexOf("/");
const range = (slash === -1 ? token : token.slice(0, slash)).trim();
const stepPart = slash === -1 ? null : token.slice(slash + 1).trim();
if (range === "") {
throw new InvalidCronError(`malformed element "${token}" in ${fieldName} field`);
}
if ((token.match(/\//g) ?? []).length > 1) {
throw new InvalidCronError(`malformed element "${token}" in ${fieldName} field`);
}
const step = stepPart === null ? 1 : parseStep(stepPart, fieldName);
let lo: number;
let hi: number;
if (range === "*") {
lo = bounds.min;
hi = bounds.max;
} else if (range.includes("-")) {
const dash = range.indexOf("-");
const loToken = range.slice(0, dash).trim();
const hiToken = range.slice(dash + 1).trim();
if (loToken === "" || hiToken === "") {
throw new InvalidCronError(`malformed range "${range}" in ${fieldName} field`);
}
lo = parseValue(loToken, bounds, fieldName);
hi = parseValue(hiToken, bounds, fieldName);
if (lo > hi) {
throw new InvalidCronError(`descending range "${range}" in ${fieldName} field (use ascending ranges)`);
}
} else {
const value = parseValue(range, bounds, fieldName);
if (stepPart === null) {
values.add(value);
continue;
}
// `n/step` means "starting at n, every step" — the vixie shorthand for n-max/step.
lo = value;
hi = bounds.max;
}
for (let v = lo; v <= hi; v += step) values.add(v);
}
if (values.size === 0) {
throw new InvalidCronError(`${fieldName} field "${field}" selects no values`);
}
// Only the bare `*` leaves a field unrestricted; `*/15`, `1-5`, lists and
// even `*,5` all constrain the field.
return { values, restricted: raw !== "*" };
}
/**
* Parse a five-field cron expression. Supported syntax: `*`, integers, ranges
* `a-b`, steps `n/step`, `a-b/step`, comma lists, and month/day-of-week names
* (`JAN-DEC`, `MON-SUN`, case-insensitive). Anything else — more or fewer than
* five fields, seconds/year fields, `@reboot` macros, `?`, `L`, `W`, `#` — is
* rejected with `InvalidCronError` rather than silently misinterpreted.
*
* Throws `ImpossibleScheduleError` when the expression can never fire (for
* example `0 0 31 2 *`).
*/
export function parseCron(expression: string): ParsedCron {
if (typeof expression !== "string") {
throw new InvalidCronError(`cron expression must be a string, got ${typeof expression}`);
}
const trimmed = expression.trim();
if (trimmed === "") {
throw new InvalidCronError("cron expression must not be empty");
}
if (trimmed.startsWith("@")) {
throw new InvalidCronError(
`cron macros like "${trimmed}" are not supported; use a five-field expression`,
);
}
for (const ch of ["?", "L", "W", "#"] as const) {
if (trimmed.includes(ch)) {
throw new InvalidCronError(
`unsupported cron syntax "${ch}" in "${expression}"; this library implements the five-field syntax only`,
);
}
}
const fields = trimmed.split(/\s+/);
if (fields.length !== 5) {
throw new InvalidCronError(
`cron expression must have exactly 5 fields (minute hour day-of-month month day-of-week), got ${fields.length} in "${expression}"`,
);
}
const minute = parseField(fields[0]!, MINUTE, FIELD_NAMES[0]);
const hour = parseField(fields[1]!, HOUR, FIELD_NAMES[1]);
const dayOfMonth = parseField(fields[2]!, DOM, FIELD_NAMES[2]);
const month = parseField(fields[3]!, MONTH, FIELD_NAMES[3]);
const dayOfWeek = parseField(fields[4]!, DOW, FIELD_NAMES[4]);
assertPossible(month, dayOfMonth);
return { minute, hour, dayOfMonth, month, dayOfWeek };
}
function assertPossible(month: CronField, dayOfMonth: CronField): void {
for (const m of month.values) {
const maxDay = MONTH_LENGTHS[m] ?? 0;
for (const d of dayOfMonth.values) {
if (d <= maxDay) return;
}
}
throw new ImpossibleScheduleError(
`expression can never fire: no allowed month is long enough for day-of-month ${[
...dayOfMonth.values,
].join(", ")}`,
);
}
/**
* Does this cron match the given local wall-clock day? Implements the vixie
* dom/dow OR rule: if both fields are restricted the day matches when either
* matches, otherwise the restricted field alone decides.
*/
export function cronMatchesDay(cron: ParsedCron, year: number, month: number, day: number): boolean {
if (!cron.month.values.has(month)) return false;
const domMatch = cron.dayOfMonth.values.has(day);
const dowMatch = cron.dayOfWeek.values.has(dayOfWeekOf(year, month, day));
const domRestricted = cron.dayOfMonth.restricted;
const dowRestricted = cron.dayOfWeek.restricted;
// Vixie rule: neither restricted -> every day; exactly one restricted ->
// that field alone; both restricted -> match if EITHER matches.
if (domRestricted && dowRestricted) return domMatch || dowMatch;
if (domRestricted) return domMatch;
if (dowRestricted) return dowMatch;
return true;
}
function dayOfWeekOf(year: number, month: number, day: number): number {
return new Date(Date.UTC(year, month - 1, day)).getUTCDay();
}
/** Does this cron match the given local time-of-day (hour/minute)? */
export function cronMatchesTime(cron: ParsedCron, hour: number, minute: number, second: number): boolean {
if (second !== 0) return false;
return cron.minute.values.has(minute) && cron.hour.values.has(hour);
}