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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
249
packages/scheduling/src/cron.ts
Normal file
249
packages/scheduling/src/cron.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue