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
56
packages/scheduling/README.md
Normal file
56
packages/scheduling/README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Scheduling
|
||||
|
||||
Shared schedule time semantics for WP3 (publishing) and WP13 (multi-output
|
||||
schedules). Pure functions and types only — no app, database or Temporal
|
||||
imports, and no runtime dependency beyond Node's `Intl` tzdata.
|
||||
|
||||
A schedule is a five-field cron expression plus an explicit IANA timezone
|
||||
(never server-local; `UTC` allowed). Occurrences are evaluated in that zone as
|
||||
nominal local date-times, then materialized to UTC fire instants. The
|
||||
occurrence key is the materialized UTC instant, so `UNIQUE (workspace_id,
|
||||
schedule_id, fire_at_utc)` holds by construction.
|
||||
|
||||
## Policies
|
||||
|
||||
- **DST gap** (nonexistent local time): fires at the first valid instant after
|
||||
the gap (shift forward). Works for 1-hour gaps and Pacific/Apia's whole-day
|
||||
skip alike.
|
||||
- **DST fold** (ambiguous local time): fires ONCE, at its first (earlier-offset)
|
||||
instant.
|
||||
- **Collapse**: several nominal local times materializing onto one post-gap
|
||||
instant produce exactly one occurrence, labelled with the latest collapsed
|
||||
slot — the conservative value for the `last_fired_nominal_local` gate.
|
||||
- **Re-materialization** on tzdata or timezone change: deletes all unclaimed
|
||||
future occurrences, then regenerates only occurrences strictly after
|
||||
max(change instant, last claimed fire instant) whose nominal slot is strictly
|
||||
later than `last_fired_nominal_local`. A slot already fired under the old
|
||||
zone (daily 09:00 fired in New York, zone switched to Los Angeles) never
|
||||
fires again. The tzdata version is reported for recording.
|
||||
|
||||
## API
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseCron, // five-field cron -> sets (rejects 6-field/`?`/`L`/`W`/`#`/macros)
|
||||
nextOccurrences, // (spec, from, count, {exclusive}) -> Occurrence[]
|
||||
materializeOccurrence, // ("2024-03-10T02:30", zone) -> epoch ms
|
||||
rematerialize, // (oldSpec, newSpec, storedRows, {changeInstant, ...})
|
||||
canonicalTimezone, // validates + canonicalizes; rejects "local"/offsets
|
||||
currentTzdataVersion, // process.versions.tz
|
||||
} from "@tactical-media-broadcast-hub/scheduling";
|
||||
```
|
||||
|
||||
Errors: `SchedulingError` base, with `InvalidCronError`, `ImpossibleScheduleError`,
|
||||
`InvalidTimezoneError`, `InvalidNominalLocalError`, `InvalidInstantError`. Note
|
||||
`toInstantMs` rejects offset-less ISO datetimes that would otherwise parse in
|
||||
the server's local zone.
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
pnpm exec tsc -p tsconfig.json --noEmit
|
||||
pnpm exec vitest run
|
||||
```
|
||||
|
||||
`pnpm install` is required once so the workspace links this package's
|
||||
`vitest`/`typescript` devDependencies into `packages/scheduling/node_modules`.
|
||||
20
packages/scheduling/package.json
Normal file
20
packages/scheduling/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "@tactical-media-broadcast-hub/scheduling",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": "./dist/index.js" },
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
99
packages/scheduling/src/cron.test.ts
Normal file
99
packages/scheduling/src/cron.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { parseCron, cronMatchesTime } from "./cron.js";
|
||||
import { ImpossibleScheduleError, InvalidCronError } from "./errors.js";
|
||||
|
||||
describe("parseCron — valid syntax", () => {
|
||||
it("parses the every-minute expression", () => {
|
||||
const c = parseCron("* * * * *");
|
||||
expect(c.minute.values.size).toBe(60);
|
||||
expect(c.minute.restricted).toBe(false);
|
||||
expect(c.dayOfMonth.restricted).toBe(false);
|
||||
expect(c.dayOfWeek.restricted).toBe(false);
|
||||
});
|
||||
it("parses steps, ranges and lists", () => {
|
||||
const c = parseCron("*/15 8-18/2 * * 1,3,5");
|
||||
expect([...c.minute.values]).toEqual([0, 15, 30, 45]);
|
||||
expect([...c.hour.values]).toEqual([8, 10, 12, 14, 16, 18]);
|
||||
expect([...c.dayOfWeek.values]).toEqual([1, 3, 5]);
|
||||
expect(c.minute.restricted).toBe(true);
|
||||
expect(c.hour.restricted).toBe(true);
|
||||
expect(c.dayOfWeek.restricted).toBe(true);
|
||||
});
|
||||
it("parses month and dow names case-insensitively", () => {
|
||||
const c = parseCron("0 0 1 jan-mar mon-fri");
|
||||
expect([...c.month.values]).toEqual([1, 2, 3]);
|
||||
expect([...c.dayOfWeek.values]).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
it("accepts a list element of one value", () => {
|
||||
expect([...parseCron("5,10 * * * *").minute.values]).toEqual([5, 10]);
|
||||
});
|
||||
it("treats n/step as n..max/step", () => {
|
||||
expect([...parseCron("20/15 * * * *").minute.values]).toEqual([20, 35, 50]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCron — rejection", () => {
|
||||
const bad = [
|
||||
"",
|
||||
" ",
|
||||
"* * * *",
|
||||
"* * * * * *",
|
||||
"60 * * * *",
|
||||
"* 24 * * *",
|
||||
"* * 0 * *",
|
||||
"* * 32 * *",
|
||||
"* * * 0 *",
|
||||
"* * * 13 *",
|
||||
"* * * * 7",
|
||||
"* * * * MON-BR",
|
||||
"a * * * *",
|
||||
"*/0 * * * *",
|
||||
"*/-1 * * * *",
|
||||
"5-1 * * * *",
|
||||
"1--2 * * * *",
|
||||
"@reboot",
|
||||
"@daily",
|
||||
"? * * * *",
|
||||
"* * ? * *",
|
||||
"* * * * * 2025",
|
||||
"0 0 * * MON#1",
|
||||
"0 0 L * *",
|
||||
"0 0 1 W *",
|
||||
"1/2/3 * * * *",
|
||||
"*/ * * * *",
|
||||
"1,,2 * * * *",
|
||||
"-5 * * * *",
|
||||
];
|
||||
it.each(bad.map((e) => [e]))("rejects %s", (expr) => {
|
||||
expect(() => parseCron(expr)).toThrow(InvalidCronError);
|
||||
});
|
||||
it("rejects non-string input", () => {
|
||||
expect(() => parseCron(undefined as unknown as string)).toThrow(InvalidCronError);
|
||||
expect(() => parseCron(5 as unknown as string)).toThrow(InvalidCronError);
|
||||
});
|
||||
it("rejects impossible schedules", () => {
|
||||
expect(() => parseCron("0 0 31 2 *")).toThrow(ImpossibleScheduleError);
|
||||
expect(() => parseCron("0 0 30 2 *")).toThrow(ImpossibleScheduleError);
|
||||
expect(() => parseCron("0 0 31 4 *")).toThrow(ImpossibleScheduleError);
|
||||
});
|
||||
it("accepts Feb 29 (leap years exist)", () => {
|
||||
expect(() => parseCron("0 0 29 2 *")).not.toThrow();
|
||||
});
|
||||
it("accepts the 31st in long months", () => {
|
||||
expect(() => parseCron("0 0 31 1 *")).not.toThrow();
|
||||
expect(() => parseCron("0 0 31 * *")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cronMatchesTime", () => {
|
||||
const cron = parseCron("*/15 9 * * *");
|
||||
it("matches on the quarter hour in the 9 hour", () => {
|
||||
expect(cronMatchesTime(cron, 9, 0, 0)).toBe(true);
|
||||
expect(cronMatchesTime(cron, 9, 45, 0)).toBe(true);
|
||||
});
|
||||
it("rejects other minutes/hours and any nonzero second", () => {
|
||||
expect(cronMatchesTime(cron, 9, 20, 0)).toBe(false);
|
||||
expect(cronMatchesTime(cron, 10, 0, 0)).toBe(false);
|
||||
expect(cronMatchesTime(cron, 9, 0, 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
31
packages/scheduling/src/errors.ts
Normal file
31
packages/scheduling/src/errors.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Error hierarchy for `@tactical-media-broadcast-hub/scheduling`.
|
||||
*
|
||||
* Every failure mode of the library is one of these types; callers can branch
|
||||
* on the class without string matching.
|
||||
*/
|
||||
export class SchedulingError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
/** The cron expression is syntactically invalid or uses unsupported syntax. */
|
||||
export class InvalidCronError extends SchedulingError {}
|
||||
|
||||
/** The timezone is not a usable IANA zone id (never silently server-local). */
|
||||
export class InvalidTimezoneError extends SchedulingError {}
|
||||
|
||||
/** A nominal local date-time string is malformed or not a real calendar date. */
|
||||
export class InvalidNominalLocalError extends SchedulingError {}
|
||||
|
||||
/**
|
||||
* An instant input (Date | number | ISO string) is not usable. Notably, an ISO
|
||||
* string that carries a time but no explicit UTC offset is rejected instead of
|
||||
* being parsed in the server's local zone.
|
||||
*/
|
||||
export class InvalidInstantError extends SchedulingError {}
|
||||
|
||||
/** The schedule can never fire (e.g. `0 0 31 2 *` — February 31st). */
|
||||
export class ImpossibleScheduleError extends SchedulingError {}
|
||||
58
packages/scheduling/src/index.ts
Normal file
58
packages/scheduling/src/index.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* @tactical-media-broadcast-hub/scheduling — shared schedule time semantics (WP3, reused by WP13).
|
||||
*
|
||||
* A schedule is a five-field cron expression plus an explicit IANA timezone
|
||||
* (never server-local; UTC allowed). Occurrences are evaluated in that zone as
|
||||
* nominal local times, then materialized to UTC fire instants. The occurrence
|
||||
* key is the materialized UTC instant, so `UNIQUE (workspace_id, schedule_id,
|
||||
* fire_at_utc)` holds by construction.
|
||||
*
|
||||
* - DST gap: a nonexistent local time fires at the first valid instant after
|
||||
* the gap (shift forward).
|
||||
* - DST fold: an ambiguous local time fires once, at its earlier instant.
|
||||
* - Collapse: several nominal local times mapping onto one post-gap instant
|
||||
* yield exactly one occurrence.
|
||||
* - Re-materialization on tzdata/timezone change deletes unclaimed future
|
||||
* occurrences and regenerates only strictly-later slots, never refiring a
|
||||
* slot already fired under the old zone.
|
||||
*
|
||||
* Pure functions only: no app, DB or Temporal imports.
|
||||
*/
|
||||
export {
|
||||
SchedulingError,
|
||||
InvalidCronError,
|
||||
InvalidTimezoneError,
|
||||
InvalidNominalLocalError,
|
||||
InvalidInstantError,
|
||||
ImpossibleScheduleError,
|
||||
} from "./errors.js";
|
||||
export {
|
||||
canonicalTimezone,
|
||||
isValidTimezone,
|
||||
assertValidTimezone,
|
||||
wallOfInstant,
|
||||
offsetMs,
|
||||
toInstantMs,
|
||||
currentTzdataVersion,
|
||||
epochOfWall,
|
||||
} from "./timezone.js";
|
||||
export type { WallClock, NominalLocal } from "./timezone.js";
|
||||
export { parseCron, cronMatchesDay, cronMatchesTime, FIELD_NAMES } from "./cron.js";
|
||||
export type { ParsedCron, CronField } from "./cron.js";
|
||||
export { formatNominal, parseNominal, compareWall, wallEquals, wallAsEpochMs } from "./nominal.js";
|
||||
export { resolveWallToInstant } from "./materialize.js";
|
||||
export { materializeOccurrence } from "./materialize-api.js";
|
||||
export {
|
||||
prepareSchedule,
|
||||
sortedHours,
|
||||
sortedMinutes,
|
||||
slotNominal,
|
||||
matchingLocalDays,
|
||||
stepOneLocalDay,
|
||||
} from "./schedule.js";
|
||||
export type { ScheduleSpec, Occurrence, PreparedSchedule } from "./schedule.js";
|
||||
export { nextOccurrencesPrepared } from "./occurrences.js";
|
||||
export type { NextOccurrencesOptions } from "./occurrences.js";
|
||||
export { nextOccurrences, materializeOccurrences } from "./occurrences_extra.js";
|
||||
export { rematerialize } from "./rematerialize.js";
|
||||
export type { StoredOccurrence, RematerializeOptions, RematerializeResult } from "./rematerialize.js";
|
||||
16
packages/scheduling/src/materialize-api.ts
Normal file
16
packages/scheduling/src/materialize-api.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { resolveWallToInstant } from "./materialize.js";
|
||||
import { canonicalTimezone } from "./timezone.js";
|
||||
import { parseNominal } from "./nominal.js";
|
||||
import type { NominalLocal } from "./timezone.js";
|
||||
|
||||
/**
|
||||
* Materialize one nominal local date-time (e.g. `2024-03-10T02:30`) in an
|
||||
* explicit IANA zone to its UTC fire instant in epoch milliseconds.
|
||||
*
|
||||
* Gap -> first valid instant after the gap; fold -> single firing at the
|
||||
* earlier instant. Throws on a malformed nominal string or an invalid zone.
|
||||
*/
|
||||
export function materializeOccurrence(nominalLocal: NominalLocal, timezone: string): number {
|
||||
const zone = canonicalTimezone(timezone);
|
||||
return resolveWallToInstant(parseNominal(nominalLocal), zone);
|
||||
}
|
||||
111
packages/scheduling/src/materialize.test.ts
Normal file
111
packages/scheduling/src/materialize.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { materializeOccurrence } from "./materialize-api.js";
|
||||
import { compareWall } from "./nominal.js";
|
||||
import { InvalidNominalLocalError } from "./errors.js";
|
||||
import { wallOfInstant } from "./timezone.js";
|
||||
|
||||
const ISO = (ms: number) => new Date(ms).toISOString().replace(".000Z", "Z");
|
||||
|
||||
describe("materializeOccurrence — normal (unambiguous) times", () => {
|
||||
it("resolves a plain New York evening slot", () => {
|
||||
expect(ISO(materializeOccurrence("2024-03-09T21:00", "America/New_York")))
|
||||
.toBe("2024-03-10T02:00:00Z");
|
||||
});
|
||||
it("passes UTC straight through", () => {
|
||||
expect(ISO(materializeOccurrence("2024-03-10T02:30", "UTC")))
|
||||
.toBe("2024-03-10T02:30:00Z");
|
||||
});
|
||||
it("resolves Kolkata (no DST, half-hour offset)", () => {
|
||||
expect(ISO(materializeOccurrence("2024-06-01T09:00", "Asia/Kolkata")))
|
||||
.toBe("2024-06-01T03:30:00Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DST gap — nonexistent local time fires at the first valid instant after", () => {
|
||||
it("2:30 on New York spring-forward (2024-03-10) shifts to 3:00 local / 07:00Z", () => {
|
||||
const fire = materializeOccurrence("2024-03-10T02:30", "America/New_York");
|
||||
expect(ISO(fire)).toBe("2024-03-10T07:00:00Z");
|
||||
const wall = wallOfInstant(fire, "America/New_York");
|
||||
expect(`${wall.hour}:${String(wall.minute).padStart(2, "0")}`).toBe("3:00");
|
||||
});
|
||||
it("2:45 shifts to 3:00 as well", () => {
|
||||
expect(ISO(materializeOccurrence("2024-03-10T02:45", "America/New_York")))
|
||||
.toBe("2024-03-10T07:00:00Z");
|
||||
});
|
||||
it("2:00 (whole gap hour) shifts to 3:00", () => {
|
||||
expect(ISO(materializeOccurrence("2024-03-10T02:00", "America/New_York")))
|
||||
.toBe("2024-03-10T07:00:00Z");
|
||||
});
|
||||
it("1:59 — last pre-gap minute — is untouched", () => {
|
||||
expect(ISO(materializeOccurrence("2024-03-10T01:59", "America/New_York")))
|
||||
.toBe("2024-03-10T06:59:00Z");
|
||||
});
|
||||
it("Berlin 02:30 spring-forward shifts to 03:00 local", () => {
|
||||
// EU transitions at 01:00 UTC: 2024-03-31 02:00->03:00 local
|
||||
expect(ISO(materializeOccurrence("2024-03-31T02:30", "Europe/Berlin")))
|
||||
.toBe("2024-03-31T01:00:00Z");
|
||||
});
|
||||
it("whole-day gap: Pacific/Apia skipped 2011-12-30; a slot on that day fires at the first valid instant after the gap", () => {
|
||||
// The zone jumped 2011-12-29T23:59:59-10:00 -> 2011-12-31T00:00:00+14:00,
|
||||
// so 2011-12-30 never existed. First valid instant after the gap is
|
||||
// 2011-12-31T00:00 local == 2011-12-30T10:00Z.
|
||||
const fire = materializeOccurrence("2011-12-30T09:00", "Pacific/Apia");
|
||||
expect(ISO(fire)).toBe("2011-12-30T10:00:00Z");
|
||||
const wall = wallOfInstant(fire, "Pacific/Apia");
|
||||
expect(`${wall.year}-${wall.month}-${String(wall.day).padStart(2, "0")}T${wall.hour}:${wall.minute}`).toBe("2011-12-31T0:0");
|
||||
});
|
||||
it("a slot just before the Apia skip (23:00 on 12-29) is untouched", () => {
|
||||
expect(ISO(materializeOccurrence("2011-12-29T23:00", "Pacific/Apia")))
|
||||
.toBe("2011-12-30T09:00:00Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DST fold — ambiguous local time fires once, at the earlier instant", () => {
|
||||
it("1:30 on New York fall-back (2024-11-03) fires at EDT (earlier offset)", () => {
|
||||
// 01:30 EDT == 05:30Z; the later reading 01:30 EST == 06:30Z is NOT used.
|
||||
const fire = materializeOccurrence("2024-11-03T01:30", "America/New_York");
|
||||
expect(ISO(fire)).toBe("2024-11-03T05:30:00Z");
|
||||
expect(fire).not.toBe(Date.parse("2024-11-03T06:30:00Z"));
|
||||
});
|
||||
it("1:30 fires exactly once at 05:30Z (not 06:30Z)", () => {
|
||||
expect(ISO(materializeOccurrence("2024-11-03T01:30", "America/New_York")))
|
||||
.toBe("2024-11-03T05:30:00Z");
|
||||
});
|
||||
it("1:00 fold hour fires at 05:00Z (earlier)", () => {
|
||||
expect(ISO(materializeOccurrence("2024-11-03T01:00", "America/New_York")))
|
||||
.toBe("2024-11-03T05:00:00Z");
|
||||
});
|
||||
it("2:00 after the fold is unambiguous again", () => {
|
||||
expect(ISO(materializeOccurrence("2024-11-03T02:00", "America/New_York")))
|
||||
.toBe("2024-11-03T07:00:00Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation", () => {
|
||||
it("rejects malformed nominal strings", () => {
|
||||
for (const bad of ["2024-3-10T02:30", "2024-03-10", "2024-03-10T2:30", "2024-13-01T00:00", "2024-03-32T00:00", "2024-03-10T25:00", "nonsense"]) {
|
||||
expect(() => materializeOccurrence(bad, "UTC")).toThrow(InvalidNominalLocalError);
|
||||
}
|
||||
});
|
||||
it("rejects invalid zones", () => {
|
||||
expect(() => materializeOccurrence("2024-03-10T02:30", "America/Nowhere")).toThrow();
|
||||
expect(() => materializeOccurrence("2024-03-10T02:30", "local")).toThrow();
|
||||
});
|
||||
it("is pure — repeated calls agree", () => {
|
||||
const a = materializeOccurrence("2024-03-10T02:30", "America/New_York");
|
||||
const b = materializeOccurrence("2024-03-10T02:30", "America/New_York");
|
||||
const c = materializeOccurrence("2024-03-10T02:30", "America/New_York");
|
||||
expect(a).toBe(b);
|
||||
expect(b).toBe(c);
|
||||
});
|
||||
it("compareWall orders wall clocks chronologically", () => {
|
||||
const w = (s: string) => ({
|
||||
year: +s.slice(0, 4), month: +s.slice(5, 7), day: +s.slice(8, 10),
|
||||
hour: +s.slice(11, 13), minute: +s.slice(14, 16), second: 0,
|
||||
});
|
||||
expect(compareWall(w("2024-03-10T01:59"), w("2024-03-10T03:00"))).toBeLessThan(0);
|
||||
expect(compareWall(w("2024-11-03T01:30"), w("2024-11-03T01:30"))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
96
packages/scheduling/src/materialize.ts
Normal file
96
packages/scheduling/src/materialize.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { compareWall, wallAsEpochMs } from "./nominal.js";
|
||||
import { offsetMs, wallOfInstant } from "./timezone.js";
|
||||
import type { WallClock } from "./timezone.js";
|
||||
|
||||
export { materializeOccurrence } from "./materialize-api.js";
|
||||
|
||||
const SIX_HOURS = 6 * 3600_000;
|
||||
|
||||
/**
|
||||
* Resolve one nominal local wall time in a zone to its UTC fire instant
|
||||
* (epoch ms).
|
||||
*
|
||||
* Normal: the unique instant reading that wall time. Fold (ambiguous): fires
|
||||
* once, at the earlier instant. Gap (nonexistent): the first valid instant
|
||||
* after the gap — the earliest instant whose local reading is strictly later
|
||||
* than the target.
|
||||
*
|
||||
* The candidate instant is found by fixed-point iteration (guess = local-as-UTC
|
||||
* minus the offset at the guess), which converges for normal and fold times.
|
||||
* Neighbouring offsets are probed to catch the second reading of a fold; the
|
||||
* earliest matching instant wins. A non-matching iteration means the wall time
|
||||
* does not exist (gap), resolved by a bracketed binary search for the exact
|
||||
* first valid instant after the transition — correct for 1-hour gaps and
|
||||
* Pacific/Apia's whole-day skip alike.
|
||||
*/
|
||||
export function resolveWallToInstant(wall: WallClock, zone: string): number {
|
||||
if (zone === "UTC") return wallAsEpochMs(wall);
|
||||
const asUtc = wallAsEpochMs(wall);
|
||||
|
||||
// Fixed-point iteration from the naive reading.
|
||||
let guess = asUtc;
|
||||
let resolved: number | null = null;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
guess = asUtc - offsetMs(guess, zone);
|
||||
if (compareWall(wallOfInstant(guess, zone), wall) === 0) {
|
||||
resolved = guess;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect every instant reading this wall time: the resolved one plus
|
||||
// candidates derived from the offsets in effect on either side of it.
|
||||
const matches: number[] = [];
|
||||
const consider = (instant: number) => {
|
||||
if (compareWall(wallOfInstant(instant, zone), wall) === 0) matches.push(instant);
|
||||
};
|
||||
if (resolved !== null) {
|
||||
consider(resolved);
|
||||
consider(asUtc - offsetMs(resolved - SIX_HOURS, zone));
|
||||
consider(asUtc - offsetMs(resolved + SIX_HOURS, zone));
|
||||
}
|
||||
if (matches.length > 0) {
|
||||
// Fold: exactly one firing, at the earliest (earlier-offset) instant.
|
||||
return Math.min(...matches);
|
||||
}
|
||||
|
||||
// Gap: the wall time does not exist. First valid instant after the gap.
|
||||
return firstInstantAfterGap(asUtc, wall, zone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Earliest instant whose wall clock in `zone` is strictly after `wall`.
|
||||
* A coarse 15-minute scan brackets the transition, then a binary search
|
||||
* narrows to the exact instant (works for 1-hour and whole-day gaps).
|
||||
*/
|
||||
function firstInstantAfterGap(asUtc: number, wall: WallClock, zone: string): number {
|
||||
const stepMs = 15 * 60_000;
|
||||
// Establish the search invariant wall(lo) <= wall first: the local-as-UTC
|
||||
// reading can already lie past the gap when the transition is large (e.g. a
|
||||
// whole-day skip), so step back until the reading is at or before the target.
|
||||
let lo = asUtc;
|
||||
for (let i = 0; i < 8 * 96 && compareWall(wallOfInstant(lo, zone), wall) > 0; i++) {
|
||||
lo -= stepMs;
|
||||
}
|
||||
for (let i = 0; i < 16 * 96; i++) {
|
||||
const next = lo + stepMs;
|
||||
if (compareWall(wallOfInstant(next, zone), wall) > 0) {
|
||||
return binarySearchFirstAfter(lo, next, wall, zone);
|
||||
}
|
||||
lo = next;
|
||||
}
|
||||
throw new Error(`no instant found after gap at ${JSON.stringify(wall)} in ${zone}`);
|
||||
}
|
||||
|
||||
/** Invariant: wall(lo) <= wall < wall(hi); returns the smallest such hi. */
|
||||
function binarySearchFirstAfter(lo: number, hi: number, wall: WallClock, zone: string): number {
|
||||
while (hi - lo > 1) {
|
||||
const mid = lo + Math.floor((hi - lo) / 2);
|
||||
if (compareWall(wallOfInstant(mid, zone), wall) > 0) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
return hi;
|
||||
}
|
||||
65
packages/scheduling/src/nominal.ts
Normal file
65
packages/scheduling/src/nominal.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { InvalidNominalLocalError } from "./errors.js";
|
||||
import type { NominalLocal, WallClock } from "./timezone.js";
|
||||
|
||||
/** Format a wall clock as a nominal local date-time string, `YYYY-MM-DDTHH:mm`. */
|
||||
export function formatNominal(wall: WallClock): NominalLocal {
|
||||
const y = String(wall.year).padStart(4, "0");
|
||||
const mo = String(wall.month).padStart(2, "0");
|
||||
const d = String(wall.day).padStart(2, "0");
|
||||
const h = String(wall.hour).padStart(2, "0");
|
||||
const mi = String(wall.minute).padStart(2, "0");
|
||||
return `${y}-${mo}-${d}T${h}:${mi}`;
|
||||
}
|
||||
|
||||
const NOMINAL_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
|
||||
|
||||
/**
|
||||
* Parse a nominal local date-time string back into a wall clock. Rejects
|
||||
* malformed strings and impossible calendar values with
|
||||
* `InvalidNominalLocalError`. Note the string may denote a wall time that does
|
||||
* not exist in a zone (gap) or exists twice (fold); existence is a
|
||||
* materialization concern, not a parse concern.
|
||||
*/
|
||||
export function parseNominal(nominal: NominalLocal): WallClock {
|
||||
const match = NOMINAL_RE.exec(nominal);
|
||||
if (!match) {
|
||||
throw new InvalidNominalLocalError(`malformed nominal local date-time: "${nominal}"`);
|
||||
}
|
||||
const wall: WallClock = {
|
||||
year: Number(match[1]),
|
||||
month: Number(match[2]),
|
||||
day: Number(match[3]),
|
||||
hour: Number(match[4]),
|
||||
minute: Number(match[5]),
|
||||
second: match[6] === undefined ? 0 : Number(match[6]),
|
||||
};
|
||||
if (wall.month < 1 || wall.month > 12) {
|
||||
throw new InvalidNominalLocalError(`month out of range in "${nominal}"`);
|
||||
}
|
||||
if (wall.day < 1 || wall.day > 31) {
|
||||
throw new InvalidNominalLocalError(`day out of range in "${nominal}"`);
|
||||
}
|
||||
if (wall.hour > 23 || wall.minute > 59 || wall.second > 59) {
|
||||
throw new InvalidNominalLocalError(`time of day out of range in "${nominal}"`);
|
||||
}
|
||||
return wall;
|
||||
}
|
||||
|
||||
/** Total ordering over wall clocks; safe because fields are validated. */
|
||||
export function compareWall(a: WallClock, b: WallClock): number {
|
||||
if (a.year !== b.year) return a.year < b.year ? -1 : 1;
|
||||
if (a.month !== b.month) return a.month < b.month ? -1 : 1;
|
||||
if (a.day !== b.day) return a.day < b.day ? -1 : 1;
|
||||
if (a.hour !== b.hour) return a.hour < b.hour ? -1 : 1;
|
||||
if (a.minute !== b.minute) return a.minute < b.minute ? -1 : 1;
|
||||
return a.second < b.second ? -1 : a.second > b.second ? 1 : 0;
|
||||
}
|
||||
|
||||
export function wallEquals(a: WallClock, b: WallClock): boolean {
|
||||
return compareWall(a, b) === 0;
|
||||
}
|
||||
|
||||
/** Milliseconds between two wall readings when interpreted as UTC. */
|
||||
export function wallAsEpochMs(wall: WallClock): number {
|
||||
return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second);
|
||||
}
|
||||
249
packages/scheduling/src/occurrences.test.ts
Normal file
249
packages/scheduling/src/occurrences.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { nextOccurrences, materializeOccurrences } from "./occurrences_extra.js";
|
||||
import { prepareSchedule } from "./schedule.js";
|
||||
import { nextOccurrencesPrepared } from "./occurrences.js";
|
||||
import { InvalidCronError } from "./errors.js";
|
||||
|
||||
const ISO = (occ: { fireAtUtc: number }) => new Date(occ.fireAtUtc).toISOString().replace(".000Z", "Z");
|
||||
|
||||
describe("UTC passthrough", () => {
|
||||
it("emits exactly the cron slots with no offset math", () => {
|
||||
const out = nextOccurrences({ cron: "0 9 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 3);
|
||||
expect(out.map(ISO)).toEqual([
|
||||
"2024-03-09T09:00:00Z",
|
||||
"2024-03-10T09:00:00Z",
|
||||
"2024-03-11T09:00:00Z",
|
||||
]);
|
||||
expect(out.map((o) => o.nominalLocal)).toEqual([
|
||||
"2024-03-09T09:00",
|
||||
"2024-03-10T09:00",
|
||||
"2024-03-11T09:00",
|
||||
]);
|
||||
});
|
||||
it("daily at 02:30 UTC is unaffected by any DST", () => {
|
||||
const out = nextOccurrences({ cron: "30 2 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 3);
|
||||
expect(out.map(ISO)).toEqual([
|
||||
"2024-03-09T02:30:00Z",
|
||||
"2024-03-10T02:30:00",
|
||||
"2024-03-11T02:30:00Z",
|
||||
].map((x) => (x.endsWith("Z") ? x : `${x}Z`)));
|
||||
});
|
||||
it("respects hour and minute fields", () => {
|
||||
const out = nextOccurrences({ cron: "15,45 6 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 4);
|
||||
expect(out.map(ISO)).toEqual([
|
||||
"2024-03-09T06:15:00Z",
|
||||
"2024-03-09T06:45:00Z",
|
||||
"2024-03-10T06:15:00Z",
|
||||
"2024-03-10T06:45:00Z",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Europe/New_York spring-forward gap (2024-03-10 02:00->03:00)", () => {
|
||||
it("daily 02:30: the gap day fires at the shifted-forward instant 03:00 local (07:00Z)", () => {
|
||||
const out = nextOccurrences({ cron: "30 2 * * *", timezone: "America/New_York" }, "2024-03-08T00:00:00Z", 3);
|
||||
expect(out.map(ISO)).toEqual([
|
||||
"2024-03-08T07:30:00Z", // 02:30 EST
|
||||
"2024-03-09T07:30:00Z", // 02:30 EST
|
||||
"2024-03-10T07:00:00Z", // 02:30 does not exist; fires 03:00 EDT
|
||||
]);
|
||||
// The nominal slot is preserved on the gap day even though the instant
|
||||
// shifted — that is what last_fired_nominal_local records.
|
||||
expect(out[2]!.nominalLocal).toBe("2024-03-10T02:30");
|
||||
});
|
||||
it("a 02:00-only schedule still fires on the gap day, shifted to 03:00 local", () => {
|
||||
const out = nextOccurrences({ cron: "0 2 * * *", timezone: "America/New_York" }, "2024-03-09T00:00:00Z", 3);
|
||||
expect(out.map(ISO)).toEqual([
|
||||
"2024-03-09T07:00:00Z", // 02:00 EST
|
||||
"2024-03-10T07:00:00Z", // 02:00 nonexistent -> 03:00 EDT
|
||||
"2024-03-11T06:00:00Z", // 02:00 EDT
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapse-dedup: */15 across the spring-forward hour fires once per instant", () => {
|
||||
it("02:00..02:45 nominal slots collapse; the gap instant appears exactly once", () => {
|
||||
const out = nextOccurrences(
|
||||
{ cron: "*/15 * * * *", timezone: "America/New_York" },
|
||||
"2024-03-10T05:00:00Z",
|
||||
8,
|
||||
);
|
||||
// 03:00 EDT == 07:00Z is the first valid instant after the gap. The slots
|
||||
// 02:00, 02:15, 02:30, 02:45 all materialize there -> ONE occurrence.
|
||||
// 05:00Z == 00:15 local. The next slots are 00:30(05:30Z), 00:45(05:45Z),
|
||||
// 01:00(06:00Z), 01:15(06:15Z), then the four nominal slots 01:30(06:30Z),
|
||||
// 01:45(06:45Z), 02:00, 02:15, 02:30, 02:45 — of which everything in the
|
||||
// gap collapses onto 03:00 local == 07:00Z — ONE occurrence — followed by
|
||||
// the normal 03:15 cadence.
|
||||
expect(out.map(ISO)).toEqual([
|
||||
"2024-03-10T05:15:00Z",
|
||||
"2024-03-10T05:30:00Z",
|
||||
"2024-03-10T05:45:00Z",
|
||||
"2024-03-10T06:00:00Z",
|
||||
"2024-03-10T06:15:00Z",
|
||||
"2024-03-10T06:30:00Z",
|
||||
"2024-03-10T06:45:00Z",
|
||||
"2024-03-10T07:00:00Z",
|
||||
]);
|
||||
// The single surviving occurrence at the gap edge carries the LATEST
|
||||
// nominal slot that collapsed onto it — the conservative never-refire label.
|
||||
expect(out[7]!.nominalLocal).toBe("2024-03-10T03:00");
|
||||
});
|
||||
it("the collapse instant appears exactly once even when asking for many", () => {
|
||||
const out = nextOccurrences({ cron: "*/15 * * * *", timezone: "America/New_York" }, "2024-03-10T05:00:00Z", 40);
|
||||
const atEdge = out.filter((o) => o.fireAtUtc === Date.parse("2024-03-10T07:00:00Z"));
|
||||
expect(atEdge.length).toBe(1);
|
||||
});
|
||||
it("produces globally unique fire instants (occurrence key uniqueness)", () => {
|
||||
const out = nextOccurrences({ cron: "*/15 * * * *", timezone: "America/New_York" }, "2024-03-09T00:00:00Z", 200);
|
||||
const instants = out.map((o) => o.fireAtUtc);
|
||||
expect(new Set(instants).size).toBe(instants.length);
|
||||
});
|
||||
it("fold hour: */15 emits each ambiguous quarter-hour once, at the earlier instant", () => {
|
||||
const out = nextOccurrences(
|
||||
{ cron: "*/15 * * * *", timezone: "America/New_York" },
|
||||
"2024-11-03T04:00:00Z",
|
||||
8,
|
||||
{ exclusive: false },
|
||||
);
|
||||
expect(out.map(ISO)).toEqual([
|
||||
"2024-11-03T04:00:00Z", // 00:00 EDT
|
||||
"2024-11-03T04:15:00Z",
|
||||
"2024-11-03T04:30:00Z",
|
||||
"2024-11-03T04:45:00Z",
|
||||
"2024-11-03T05:00:00Z", // 01:00 EDT (earlier reading)
|
||||
"2024-11-03T05:15:00Z",
|
||||
"2024-11-03T05:30:00Z",
|
||||
"2024-11-03T05:45:00Z",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fall-back fold — daily 01:30 fires once at the earlier instant", () => {
|
||||
it("fires 05:30Z (EDT), never 06:30Z, and continues next day", () => {
|
||||
const out = nextOccurrences({ cron: "30 1 * * *", timezone: "America/New_York" }, "2024-11-01T00:00:00Z", 3);
|
||||
expect(out.map(ISO)).toEqual([
|
||||
"2024-11-01T05:30:00Z", // 01:30 EDT
|
||||
"2024-11-02T05:30:00Z", // 01:30 EDT
|
||||
"2024-11-03T05:30:00Z", // 01:30 exists twice; fires ONCE at EDT (05:30Z)
|
||||
]);
|
||||
// and never the later EST reading
|
||||
expect(out.map(ISO)).not.toContain("2024-11-03T06:30:00Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron day-of-month/month handling", () => {
|
||||
it("monthly on the 1st at 09:00", () => {
|
||||
const out = nextOccurrences({ cron: "0 9 1 * *", timezone: "UTC" }, "2024-01-15T00:00:00Z", 3);
|
||||
expect(out.map(ISO)).toEqual([
|
||||
"2024-02-01T09:00:00Z",
|
||||
"2024-03-01T09:00:00Z",
|
||||
"2024-04-01T09:00:00Z",
|
||||
]);
|
||||
});
|
||||
it("monthly last-day-ish: the 31st only in long months", () => {
|
||||
const out = nextOccurrences({ cron: "0 9 31 * *", timezone: "UTC" }, "2024-01-01T00:00:00Z", 4);
|
||||
expect(out.map(ISO)).toEqual([
|
||||
"2024-01-31T09:00:00Z",
|
||||
"2024-03-31T09:00:00Z",
|
||||
"2024-05-31T09:00:00Z",
|
||||
"2024-07-31T09:00:00Z",
|
||||
]);
|
||||
});
|
||||
it("day-of-week: weekly on Monday 09:00", () => {
|
||||
const out = nextOccurrences({ cron: "0 9 * * 1", timezone: "UTC" }, "2024-03-08T00:00:00Z", 3);
|
||||
// 2024-03-08 is a Friday and does not match; the Mondays follow.
|
||||
expect(out.map(ISO)).toEqual([
|
||||
"2024-03-11T09:00:00Z",
|
||||
"2024-03-18T09:00:00Z",
|
||||
"2024-03-25T09:00:00Z",
|
||||
]);
|
||||
expect(out.every((o) => new Date(o.fireAtUtc).getUTCDay() === 1)).toBe(true);
|
||||
});
|
||||
it("dom/dow OR rule: both restricted means either matches", () => {
|
||||
// 13th OR every Friday (2024-03-01 is itself a Friday but sits exactly at
|
||||
// `from`, so the exclusive default drops it; 03-02 would keep it).
|
||||
const out = nextOccurrences({ cron: "0 0 13 * 5", timezone: "UTC" }, "2024-03-01T00:00:00Z", 6);
|
||||
expect(out.map((o) => o.nominalLocal)).toEqual([
|
||||
"2024-03-08T00:00",
|
||||
"2024-03-13T00:00",
|
||||
"2024-03-15T00:00",
|
||||
"2024-03-22T00:00",
|
||||
"2024-03-29T00:00",
|
||||
"2024-04-05T00:00",
|
||||
]);
|
||||
});
|
||||
it("dow-only restriction drives the day when dom is *", () => {
|
||||
// Mondays and Fridays only
|
||||
const out = nextOccurrences({ cron: "0 12 * * 1,5", timezone: "UTC" }, "2024-03-04T00:00:00Z", 4);
|
||||
expect(out.map((o) => o.nominalLocal)).toEqual([
|
||||
"2024-03-04T12:00", // Monday
|
||||
"2024-03-08T12:00", // Friday
|
||||
"2024-03-11T12:00", // Monday
|
||||
"2024-03-15T12:00", // Friday
|
||||
]);
|
||||
});
|
||||
it("hour range with step: 8-18/2 hours", () => {
|
||||
const out = nextOccurrences({ cron: "0 8-18/2 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 7);
|
||||
expect(out.map((o) => o.nominalLocal.slice(11))).toEqual(["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "08:00"]);
|
||||
});
|
||||
it("month names and dow names", () => {
|
||||
const out = nextOccurrences({ cron: "0 9 1 JAN-MAR *", timezone: "UTC" }, "2024-01-02T00:00:00Z", 2);
|
||||
expect(out.map(ISO)).toEqual(["2024-02-01T09:00:00Z", "2024-03-01T09:00:00Z"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pacific/Apia whole-day skip in generation", () => {
|
||||
it("daily 09:00 skips 2011-12-30 entirely and resumes 12-31", () => {
|
||||
const out = nextOccurrences({ cron: "0 9 * * *", timezone: "Pacific/Apia" }, "2011-12-28T00:00:00Z", 3);
|
||||
expect(out.map((o) => o.nominalLocal)).toEqual([
|
||||
"2011-12-28T09:00",
|
||||
"2011-12-29T09:00",
|
||||
// The skipped day's slot (12-30 09:00) still fires — shifted forward to
|
||||
// the first valid instant — and keeps its own nominal label so it can
|
||||
// never be refired as a "new" slot later.
|
||||
"2011-12-30T09:00",
|
||||
]);
|
||||
// Its fire instant is the zone's first valid instant after the skip.
|
||||
expect(new Date(out[2]!.fireAtUtc).toISOString()).toBe("2011-12-30T10:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generation hygiene", () => {
|
||||
it("prepared and unprepared agree", () => {
|
||||
const spec = { cron: "*/20 * * * *", timezone: "America/New_York" };
|
||||
const a = nextOccurrences(spec, "2024-03-09T00:00:00Z", 50);
|
||||
const b = nextOccurrencesPrepared(prepareSchedule(spec), "2024-03-09T00:00:00Z", 50);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
it("exclusive default: regenerating from the last emitted instant does not re-emit it", () => {
|
||||
const spec = { cron: "0 * * * *", timezone: "UTC" };
|
||||
const first = nextOccurrences(spec, "2024-03-09T00:00:00Z", 5);
|
||||
const last = first[first.length - 1]!;
|
||||
const second = nextOccurrences(spec, last.fireAtUtc, 5);
|
||||
expect(second[0]!.fireAtUtc).toBeGreaterThan(last.fireAtUtc);
|
||||
expect(second.map(ISO)).toEqual([
|
||||
"2024-03-09T06:00:00Z",
|
||||
"2024-03-09T07:00:00Z",
|
||||
"2024-03-09T08:00:00Z",
|
||||
"2024-03-09T09:00:00Z",
|
||||
"2024-03-09T10:00:00Z",
|
||||
]);
|
||||
});
|
||||
it("inclusive option includes a slot exactly at from", () => {
|
||||
const out = nextOccurrences({ cron: "0 * * * *", timezone: "UTC" }, "2024-03-09T05:00:00Z", 2, { exclusive: false });
|
||||
expect(out.map(ISO)).toEqual(["2024-03-09T05:00:00Z", "2024-03-09T06:00:00Z"]);
|
||||
});
|
||||
it("rejects a bad count", () => {
|
||||
expect(() => nextOccurrences({ cron: "* * * * *", timezone: "UTC" }, "2024-01-01T00:00:00Z", 0)).toThrow(RangeError);
|
||||
expect(() => nextOccurrences({ cron: "* * * * *", timezone: "UTC" }, "2024-01-01T00:00:00Z", 1.5)).toThrow(RangeError);
|
||||
});
|
||||
it("materializeOccurrences is the generation batch", () => {
|
||||
const out = materializeOccurrences({ cron: "0 9 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 2);
|
||||
expect(out.map(ISO)).toEqual(["2024-03-09T09:00:00Z", "2024-03-10T09:00:00Z"]);
|
||||
});
|
||||
it("rejects invalid cron/zone at generation time", () => {
|
||||
expect(() => nextOccurrences({ cron: "not cron", timezone: "UTC" }, "2024-01-01T00:00:00Z", 1)).toThrow(InvalidCronError);
|
||||
expect(() => nextOccurrences({ cron: "* * * * *", timezone: "local" }, "2024-01-01T00:00:00Z", 1)).toThrow();
|
||||
});
|
||||
});
|
||||
120
packages/scheduling/src/occurrences.ts
Normal file
120
packages/scheduling/src/occurrences.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { cronMatchesDay } from "./cron.js";
|
||||
import { resolveWallToInstant } from "./materialize.js";
|
||||
import { slotNominal, sortedHours, sortedMinutes, type PreparedSchedule } from "./schedule.js";
|
||||
import { toInstantMs, wallOfInstant } from "./timezone.js";
|
||||
import type { WallClock } from "./timezone.js";
|
||||
import type { Occurrence } from "./schedule.js";
|
||||
|
||||
export interface NextOccurrencesOptions {
|
||||
/**
|
||||
* When true (default) a slot whose fire instant is exactly `from` is
|
||||
* skipped; when false it is included. The default keeps re-generation
|
||||
* idempotent: re-running generation from the last emitted instant never
|
||||
* re-emits it.
|
||||
*/
|
||||
readonly exclusive?: boolean;
|
||||
}
|
||||
|
||||
|
||||
/** Nominal calendar day (local midnight reading) containing `epochMs`. */
|
||||
function startNominalDay(epochMs: number, zone: string): WallClock {
|
||||
const wall = wallOfInstant(epochMs, zone);
|
||||
return { year: wall.year, month: wall.month, day: wall.day, hour: 0, minute: 0, second: 0 };
|
||||
}
|
||||
|
||||
/** Advance a nominal calendar day by one, purely calendar arithmetic. */
|
||||
function nextNominalDay(day: WallClock): WallClock {
|
||||
const next = new Date(Date.UTC(day.year, day.month - 1, day.day + 1));
|
||||
return {
|
||||
year: next.getUTCFullYear(),
|
||||
month: next.getUTCMonth() + 1,
|
||||
day: next.getUTCDate(),
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The next `count` occurrences at or after `from`, evaluated in the schedule
|
||||
* zone and materialized to UTC instants.
|
||||
*
|
||||
* Each local day's matching slots are emitted in ascending local order; a
|
||||
* slot's materialization (gap shift-forward, fold earlier-instant) may collide
|
||||
* with another slot's instant — such collisions are deduplicated so each
|
||||
* materialized instant appears exactly once (occurrence key stays unique).
|
||||
* Generation is capped at `maxYears` of local days to bound work for
|
||||
* sparse schedules.
|
||||
*/
|
||||
export function nextOccurrencesPrepared(
|
||||
prepared: PreparedSchedule,
|
||||
from: Date | number | string,
|
||||
count: number,
|
||||
options?: NextOccurrencesOptions,
|
||||
maxYears = 8,
|
||||
): Occurrence[] {
|
||||
if (!Number.isInteger(count) || count <= 0) {
|
||||
throw new RangeError(`count must be a positive integer, got ${count}`);
|
||||
}
|
||||
const zone = prepared.timezone;
|
||||
const cron = prepared.cron;
|
||||
const fromMs = toInstantMs(from);
|
||||
const exclusive = options?.exclusive ?? true;
|
||||
const hours = sortedHours(cron);
|
||||
const minutes = sortedMinutes(cron);
|
||||
const out: Occurrence[] = [];
|
||||
const seen = new Set<number>();
|
||||
// Index of the emitted occurrence per fire instant, so a later nominal slot
|
||||
// collapsing onto an already-emitted instant can take over the label.
|
||||
const indexAt = new Map<number, number>();
|
||||
const dayLimit = Math.ceil(maxYears * 366);
|
||||
// Iterate NOMINAL calendar days (not materialized midnights) so a skipped
|
||||
// local day (Pacific/Apia 2011-12-30) still contributes its slots: every
|
||||
// slot of that day materializes to the first valid instant after the gap and
|
||||
// collapses onto a single occurrence there.
|
||||
let day = startNominalDay(fromMs, zone);
|
||||
// Once `count` occurrences exist, generation may continue only far enough to
|
||||
// finish the collapse set of the LAST emitted instant — later slots of that
|
||||
// same instant must still be able to take over its nominal label — and then
|
||||
// stops. Occurrences are emitted in ascending instant order, so the first
|
||||
// slot materializing strictly after the boundary ends generation.
|
||||
let boundary: number | null = null;
|
||||
for (let dayIndex = 0; dayIndex < dayLimit; dayIndex++) {
|
||||
if (cronMatchesDay(cron, day.year, day.month, day.day)) {
|
||||
for (const hour of hours) {
|
||||
for (const minute of minutes) {
|
||||
const slotWall: WallClock = {
|
||||
year: day.year, month: day.month, day: day.day,
|
||||
hour, minute, second: 0,
|
||||
};
|
||||
const fireAt = resolveWallToInstant(slotWall, zone);
|
||||
if (exclusive ? fireAt <= fromMs : fireAt < fromMs) continue;
|
||||
if (boundary !== null && fireAt > boundary) return out;
|
||||
if (seen.has(fireAt)) {
|
||||
// Collapse: several nominal slots materialized onto one instant
|
||||
// (post-gap). Exactly one occurrence survives per instant, carrying
|
||||
// the LATEST collapsed nominal slot — the conservative value for
|
||||
// the last_fired_nominal_local never-refire gate.
|
||||
const index = indexAt.get(fireAt);
|
||||
if (index !== undefined) {
|
||||
out[index] = {
|
||||
nominalLocal: slotNominal(day.year, day.month, day.day, hour, minute),
|
||||
fireAtUtc: fireAt,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
seen.add(fireAt);
|
||||
indexAt.set(fireAt, out.length);
|
||||
out.push({
|
||||
nominalLocal: slotNominal(day.year, day.month, day.day, hour, minute),
|
||||
fireAtUtc: fireAt,
|
||||
});
|
||||
if (out.length >= count) boundary = fireAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
day = nextNominalDay(day);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
26
packages/scheduling/src/occurrences_extra.ts
Normal file
26
packages/scheduling/src/occurrences_extra.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Convenience wrappers kept in a separate module so occurrences.ts stays a
|
||||
// single-purpose iterator. Re-exported through index.ts.
|
||||
import { nextOccurrencesPrepared } from "./occurrences.js";
|
||||
import { prepareSchedule, type Occurrence, type ScheduleSpec } from "./schedule.js";
|
||||
|
||||
/** The next `count` occurrences of `spec` at or after `from` (strictly after). */
|
||||
export function nextOccurrences(
|
||||
spec: ScheduleSpec,
|
||||
from: Date | number | string,
|
||||
count: number,
|
||||
options?: { exclusive?: boolean },
|
||||
): Occurrence[] {
|
||||
return nextOccurrencesPrepared(prepareSchedule(spec), from, count, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Occurrence rows a worker should insert for `spec` from an instant — the
|
||||
* materialization batch keyed by (schedule_id, fire_at_utc).
|
||||
*/
|
||||
export function materializeOccurrences(
|
||||
spec: ScheduleSpec,
|
||||
from: Date | number | string,
|
||||
count: number,
|
||||
): Occurrence[] {
|
||||
return nextOccurrences(spec, from, count);
|
||||
}
|
||||
131
packages/scheduling/src/property.test.ts
Normal file
131
packages/scheduling/src/property.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { nextOccurrences } from "./occurrences_extra.js";
|
||||
import { materializeOccurrence } from "./materialize-api.js";
|
||||
import { wallOfInstant } from "./timezone.js";
|
||||
|
||||
/**
|
||||
* Brute-force oracle: scan every minute with Intl and apply the spec rules
|
||||
* independently of the engine's search strategy.
|
||||
*
|
||||
* - a wall reading that matches a cron slot fires at that instant;
|
||||
* - a fold (reading repeats one already seen) fires only at its first instant;
|
||||
* - a gap (the wall clock jumps forward over slot(s)) fires at the instant of
|
||||
* the jump, labelled with the LATEST skipped slot;
|
||||
* - several slots landing on one instant collapse to a single occurrence.
|
||||
*/
|
||||
function oracle(cronMinutes: number[], cronHours: number[], zone: string, fromMs: number, toMs: number) {
|
||||
const wanted = new Set<string>();
|
||||
for (const h of cronHours) for (const m of cronMinutes) wanted.add(`${h}:${m}`);
|
||||
const wallKey = (y: number, mo: number, d: number, h: number, mi: number) =>
|
||||
Date.UTC(y, mo - 1, d, h, mi);
|
||||
const byInstant = new Map<number, string>();
|
||||
const seenWalls = new Set<number>();
|
||||
let prevKey: number | null = null;
|
||||
for (let t = fromMs; t <= toMs - 60_000; t += 60_000) {
|
||||
const w = wallOfInstant(t, zone);
|
||||
const key = wallKey(w.year, w.month, w.day, w.hour, w.minute);
|
||||
const label = `${w.year}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}`;
|
||||
// Fold: this wall reading already fired at its earlier instant.
|
||||
if (seenWalls.has(key)) {
|
||||
prevKey = key;
|
||||
continue;
|
||||
}
|
||||
seenWalls.add(key);
|
||||
// Gap: the wall clock jumped. Any wanted slot strictly between the previous
|
||||
// reading and this one did not exist; it fires HERE (shift forward), and
|
||||
// several skipped slots collapse onto this single instant.
|
||||
if (prevKey !== null && key - prevKey > 60_000) {
|
||||
for (let k = prevKey + 60_000; k < key; k += 60_000) {
|
||||
const kk = new Date(k);
|
||||
if (wanted.has(`${kk.getUTCHours()}:${kk.getUTCMinutes()}`)) {
|
||||
const d = kk.getUTCDate();
|
||||
const mo = kk.getUTCMonth() + 1;
|
||||
const y = kk.getUTCFullYear();
|
||||
byInstant.set(t, `${y}-${pad(mo)}-${pad(d)}T${pad(kk.getUTCHours())}:${pad(kk.getUTCMinutes())}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (wanted.has(`${w.hour}:${w.minute}`)) {
|
||||
byInstant.set(t, label);
|
||||
}
|
||||
prevKey = key;
|
||||
}
|
||||
return [...byInstant.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([fireAtUtc, nominalLocal]) => ({ nominalLocal, fireAtUtc }));
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
describe("cross-zone property: generator matches a brute-force minute oracle", () => {
|
||||
const cases: Array<{ name: string; zone: string; from: string; to: string; cron: string }> = [
|
||||
{ name: "NY hourly across both 2024 transitions", zone: "America/New_York", from: "2024-03-09T00:00:00Z", to: "2024-03-12T00:00:00Z", cron: "0 * * * *" },
|
||||
{ name: "NY quarter-hours across spring-forward", zone: "America/New_York", from: "2024-03-10T00:00:00Z", to: "2024-03-11T00:00:00Z", cron: "*/15 * * * *" },
|
||||
{ name: "NY quarter-hours across fall-back", zone: "America/New_York", from: "2024-11-03T00:00:00Z", to: "2024-11-04T00:00:00Z", cron: "*/15 * * * *" },
|
||||
{ name: "Berlin hourly across spring-forward", zone: "Europe/Berlin", from: "2024-03-30T00:00:00Z", to: "2024-04-02T00:00:00Z", cron: "0 * * * *" },
|
||||
{ name: "Apia daily across the 2011 whole-day skip", zone: "Pacific/Apia", from: "2011-12-27T00:00:00Z", to: "2012-01-02T00:00:00Z", cron: "0 9 * * *" },
|
||||
{ name: "Apia hourly across the skip", zone: "Pacific/Apia", from: "2011-12-29T00:00:00Z", to: "2011-12-31T00:00:00Z", cron: "30 * * * *" },
|
||||
{ name: "Lord Howe 30-minute transition (spring)", zone: "Australia/Lord_Howe", from: "2024-10-04T00:00:00Z", to: "2024-10-08T00:00:00Z", cron: "0 * * * *" },
|
||||
{ name: "Lord Howe 30-minute transition (fall)", zone: "Australia/Lord_Howe", from: "2024-04-04T00:00:00Z", to: "2024-04-08T00:00:00Z", cron: "0 * * * *" },
|
||||
{ name: "Kathmandu 45-minute offset (no DST)", zone: "Asia/Kathmandu", from: "2024-03-01T00:00:00Z", to: "2024-03-04T00:00:00Z", cron: "*/20 * * * *" },
|
||||
{ name: "Chatham Islands 45-min offset with DST", zone: "Pacific/Chatham", from: "2024-04-05T00:00:00Z", to: "2024-04-09T00:00:00Z", cron: "0 * * * *" },
|
||||
{ name: "UTC passthrough", zone: "UTC", from: "2024-03-09T00:00:00Z", to: "2024-03-11T00:00:00Z", cron: "*/30 * * * *" },
|
||||
{ name: "Santiago delayed transition (midnight)", zone: "America/Santiago", from: "2024-09-07T00:00:00Z", to: "2024-09-10T00:00:00Z", cron: "0 * * * *" },
|
||||
];
|
||||
|
||||
it.each(cases.map((c) => [c.name, c] as const))("%s", (_name, c) => {
|
||||
const fromMs = Date.parse(c.from);
|
||||
const toMs = Date.parse(c.to);
|
||||
// Derive the oracle's slot sets from the same cron fields the generator
|
||||
// parses, so the comparison exercises materialization and ordering — not
|
||||
// cron parsing (covered separately in cron.test.ts).
|
||||
const fields = c.cron.split(/\s+/);
|
||||
const minuteField = fields[0]!;
|
||||
const hourField = fields[1]!;
|
||||
const minutes = minuteField.startsWith("*/")
|
||||
? Array.from({ length: Math.ceil(60 / Number(minuteField.slice(2))) }, (_, i) => i * Number(minuteField.slice(2))).filter((m) => m < 60)
|
||||
: minuteField.split(",").map(Number);
|
||||
const hours = hourField === "*" ? Array.from({ length: 24 }, (_, i) => i) : hourField.split(",").map(Number);
|
||||
const wanted = oracle(minutes, hours, c.zone, fromMs, toMs);
|
||||
// Inclusive so the generator's window matches the oracle's inclusive scan.
|
||||
const got = nextOccurrences({ cron: c.cron, timezone: c.zone }, c.from, wanted.length || 1, { exclusive: false });
|
||||
expect(got.map((o) => [o.nominalLocal, o.fireAtUtc])).toEqual(wanted.map((o) => [o.nominalLocal, o.fireAtUtc]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("materializeOccurrence inverts wallOfInstant", () => {
|
||||
const zones = ["America/New_York", "Europe/Berlin", "Pacific/Apia", "Australia/Lord_Howe", "Pacific/Chatham", "Asia/Kathmandu", "UTC"];
|
||||
// Hourly around each hemisphere's 2024 transitions (where inversions can
|
||||
// happen) plus a coarse 12-hourly annual sweep.
|
||||
const windows = [
|
||||
["2024-03-08T00:00:00Z", "2024-03-13T00:00:00Z"],
|
||||
["2024-11-01T00:00:00Z", "2024-11-06T00:00:00Z"],
|
||||
["2024-09-28T00:00:00Z", "2024-10-10T00:00:00Z"],
|
||||
["2024-04-01T00:00:00Z", "2024-04-09T00:00:00Z"],
|
||||
["2011-12-27T00:00:00Z", "2012-01-03T00:00:00Z"],
|
||||
] as const;
|
||||
const steps: Array<[number, number]> = [
|
||||
...windows.map((w) => [Date.parse(w[0]), Date.parse(w[1])] as [number, number]),
|
||||
[Date.parse("2024-01-01T00:00:00Z"), Date.parse("2025-01-01T00:00:00Z")],
|
||||
];
|
||||
it.each(zones.map((z) => [z]))("round-trips wall readings in %s", (zone) => {
|
||||
let folds = 0;
|
||||
let checked = 0;
|
||||
for (const [start, end] of steps) {
|
||||
const step = end - start > 20 * 86400_000 ? 12 * 3600_000 : 3600_000;
|
||||
for (let t = start; t < end; t += step) {
|
||||
const w = wallOfInstant(t, zone);
|
||||
const nominal = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}T${String(w.hour).padStart(2, "0")}:${String(w.minute).padStart(2, "0")}`;
|
||||
const back = materializeOccurrence(nominal, zone);
|
||||
checked++;
|
||||
if (back === t) continue;
|
||||
// Only fold readings may differ, and only by the offset delta (back < t).
|
||||
expect(back).toBeLessThan(t);
|
||||
expect(t - back).toBeLessThanOrEqual(3600_000);
|
||||
folds++;
|
||||
}
|
||||
}
|
||||
expect(checked).toBeGreaterThan(100);
|
||||
expect(folds).toBeLessThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
198
packages/scheduling/src/rematerialize.test.ts
Normal file
198
packages/scheduling/src/rematerialize.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { rematerialize } from "./rematerialize.js";
|
||||
import { nextOccurrences } from "./occurrences_extra.js";
|
||||
import type { StoredOccurrence } from "./rematerialize.js";
|
||||
|
||||
/** Build stored rows as a worker would have persisted them. */
|
||||
function storedFrom(spec: { cron: string; timezone: string }, from: string, count: number): StoredOccurrence[] {
|
||||
return nextOccurrences(spec, from, count).map((o) => ({
|
||||
nominalLocal: o.nominalLocal,
|
||||
fireAtUtc: o.fireAtUtc,
|
||||
claimed: false,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("rematerialize — invalidation and regeneration", () => {
|
||||
it("deletes unclaimed future occurrences and regenerates from the change instant", () => {
|
||||
const oldSpec = { cron: "0 9 * * *", timezone: "America/New_York" };
|
||||
const stored = storedFrom(oldSpec, "2024-03-01T00:00:00Z", 5);
|
||||
// One past occurrence was claimed.
|
||||
stored[0] = { ...stored[0]!, claimed: true };
|
||||
const changeInstant = "2024-03-02T00:00:00Z";
|
||||
|
||||
const result = rematerialize(oldSpec, { cron: "0 10 * * *", timezone: "America/New_York" }, stored, {
|
||||
changeInstant,
|
||||
lastFiredNominalLocal: stored[0]!.nominalLocal,
|
||||
count: 3,
|
||||
});
|
||||
|
||||
// Unclaimed future rows are invalidated.
|
||||
expect(result.toDeleteFireAtUtc).toEqual(
|
||||
stored.slice(1).map((r) => r.fireAtUtc),
|
||||
);
|
||||
// New occurrences start strictly after the change instant and the last claim.
|
||||
expect(result.toInsert.length).toBe(3);
|
||||
expect(result.toInsert[0]!.fireAtUtc).toBeGreaterThan(Date.parse(changeInstant));
|
||||
expect(result.toInsert[0]!.nominalLocal).toBe("2024-03-02T10:00");
|
||||
// The new tzdata version is reported for recording.
|
||||
expect(result.tzdataVersion).toMatch(/.+/);
|
||||
});
|
||||
|
||||
it("never refires a nominal slot already fired under the old zone (NY -> LA same day)", () => {
|
||||
// Daily 09:00. Under New York the 2024-03-01 09:00 slot fired at 14:00Z.
|
||||
const oldSpec = { cron: "0 9 * * *", timezone: "America/New_York" };
|
||||
const stored: StoredOccurrence[] = [
|
||||
{ nominalLocal: "2024-03-01T09:00", fireAtUtc: Date.parse("2024-03-01T14:00:00Z"), claimed: true },
|
||||
...storedFrom(oldSpec, "2024-03-02T00:00:00Z", 3),
|
||||
];
|
||||
const result = rematerialize(oldSpec, { cron: "0 9 * * *", timezone: "America/Los_Angeles" }, stored, {
|
||||
changeInstant: "2024-03-01T15:00:00Z", // later the same day, after the NY firing
|
||||
lastFiredNominalLocal: "2024-03-01T09:00",
|
||||
count: 3,
|
||||
});
|
||||
// 09:00 in Los Angeles on 03-01 is 17:00Z — but that nominal slot was
|
||||
// already fired under New York, so it must NOT appear.
|
||||
expect(result.toInsert.map((o) => o.nominalLocal)).not.toContain("2024-03-01T09:00");
|
||||
expect(result.toInsert.map((o) => o.nominalLocal)).toEqual([
|
||||
"2024-03-02T09:00",
|
||||
"2024-03-03T09:00",
|
||||
"2024-03-04T09:00",
|
||||
].slice(0, 3));
|
||||
expect(result.toInsert[0]!.fireAtUtc).toBe(Date.parse("2024-03-02T17:00:00Z"));
|
||||
});
|
||||
|
||||
it("claimed rows are never deleted", () => invariants());
|
||||
|
||||
function invariants(): boolean {
|
||||
const oldSpec = { cron: "0 9 * * *", timezone: "America/New_York" };
|
||||
const stored = storedFrom(oldSpec, "2024-03-01T00:00:00Z", 5);
|
||||
stored[0] = { ...stored[0]!, claimed: true };
|
||||
const result = rematerialize(oldSpec, oldSpec, stored, {
|
||||
changeInstant: "2024-03-02T00:00:00Z",
|
||||
lastFiredNominalLocal: stored[0]!.nominalLocal,
|
||||
count: 3,
|
||||
});
|
||||
expect(result.toDeleteFireAtUtc).not.toContain(stored[0]!.fireAtUtc);
|
||||
return true;
|
||||
}
|
||||
|
||||
it("inserts never collide with SURVIVING rows (deletes are applied first)", () => {
|
||||
const oldSpec = { cron: "0 9 * * *", timezone: "America/New_York" };
|
||||
const stored = storedFrom(oldSpec, "2024-03-01T00:00:00Z", 5);
|
||||
stored[0] = { ...stored[0]!, claimed: true };
|
||||
const result = rematerialize(oldSpec, oldSpec, stored, {
|
||||
changeInstant: "2024-03-01T00:00:00Z",
|
||||
lastFiredNominalLocal: stored[0]!.nominalLocal,
|
||||
count: 10,
|
||||
});
|
||||
const deleting = new Set(result.toDeleteFireAtUtc);
|
||||
const surviving = stored.filter((r) => !deleting.has(r.fireAtUtc)).map((r) => r.fireAtUtc);
|
||||
expect(surviving).toEqual([stored[0]!.fireAtUtc]); // only the claimed row survives
|
||||
for (const o of result.toInsert) {
|
||||
expect(surviving.includes(o.fireAtUtc)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("unchanged spec regenerates the same instants it deleted (idempotent)", () => {
|
||||
const spec = { cron: "0 9 * * *", timezone: "America/New_York" };
|
||||
const stored = storedFrom(spec, "2024-03-01T00:00:00Z", 5);
|
||||
stored[0] = { ...stored[0]!, claimed: true };
|
||||
const result = rematerialize(spec, spec, stored, {
|
||||
changeInstant: "2024-03-01T00:00:00Z",
|
||||
lastFiredNominalLocal: stored[0]!.nominalLocal,
|
||||
count: stored.length - 1, // regenerate exactly the deleted horizon
|
||||
});
|
||||
const deletedSorted = [...result.toDeleteFireAtUtc].sort((a, b) => a - b);
|
||||
expect(result.toInsert.map((o) => o.fireAtUtc)).toEqual(deletedSorted);
|
||||
});
|
||||
|
||||
it("toInsert fire instants are unique among themselves and cross the gap once", () => {
|
||||
const spec = { cron: "*/15 * * * *", timezone: "America/New_York" };
|
||||
const stored = storedFrom(spec, "2024-03-08T00:00:00Z", 20);
|
||||
// 150 quarter-hours from 03-09 00:15Z reaches past the 03-10 07:00Z edge.
|
||||
const result = rematerialize(spec, spec, stored, { changeInstant: "2024-03-09T00:00:00Z", count: 150 });
|
||||
const instants = result.toInsert.map((o) => o.fireAtUtc);
|
||||
expect(new Set(instants).size).toBe(instants.length);
|
||||
const edge = instants.filter((i) => i === Date.parse("2024-03-10T07:00:00Z"));
|
||||
expect(edge.length).toBe(1);
|
||||
// The nominal slots 02:00..02:45 collapse onto that one instant, and the
|
||||
// surviving occurrence carries the latest collapsed nominal slot.
|
||||
const atEdge = result.toInsert.find((o) => o.fireAtUtc === Date.parse("2024-03-10T07:00:00Z"));
|
||||
expect(atEdge?.nominalLocal).toBe("2024-03-10T03:00");
|
||||
});
|
||||
|
||||
it("tzdata-only change (same zone) still regenerates correctly", () => {
|
||||
const spec = { cron: "0 9 * * *", timezone: "America/New_York" };
|
||||
const stored = storedFrom(spec, "2024-03-01T00:00:00Z", 3);
|
||||
const result = rematerialize(spec, spec, stored, {
|
||||
changeInstant: "2024-03-01T00:00:00Z",
|
||||
tzdataVersion: "2025a",
|
||||
count: 2,
|
||||
});
|
||||
expect(result.tzdataVersion).toBe("2025a");
|
||||
expect(result.toInsert.length).toBe(2);
|
||||
});
|
||||
|
||||
it("reports the current tzdata version when not overridden", () => {
|
||||
const spec = { cron: "0 9 * * *", timezone: "UTC" };
|
||||
const result = rematerialize(spec, spec, [], { changeInstant: "2024-01-01T00:00:00Z" });
|
||||
expect(typeof result.tzdataVersion).toBe("string");
|
||||
expect(result.tzdataVersion.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("a schedule with no history regenerates from the change instant alone", () => {
|
||||
const spec = { cron: "0 9 * * *", timezone: "UTC" };
|
||||
const result = rematerialize(spec, { ...spec, timezone: "Europe/Berlin" }, [], {
|
||||
changeInstant: "2024-03-05T00:00:00Z",
|
||||
count: 2,
|
||||
});
|
||||
expect(result.toInsert.map((o) => o.nominalLocal)).toEqual([
|
||||
"2024-03-05T09:00",
|
||||
"2024-03-06T09:00",
|
||||
]);
|
||||
expect(result.toDeleteFireAtUtc).toEqual([]);
|
||||
});
|
||||
|
||||
it("unclaimed PAST occurrences are not deleted (only future ones)", () => {
|
||||
const spec = { cron: "0 9 * * *", timezone: "UTC" };
|
||||
const stored: StoredOccurrence[] = [
|
||||
{ nominalLocal: "2024-02-01T09:00", fireAtUtc: Date.parse("2024-02-01T09:00:00Z"), claimed: false },
|
||||
{ nominalLocal: "2024-04-01T09:00", fireAtUtc: Date.parse("2024-04-01T09:00:00Z"), claimed: false },
|
||||
];
|
||||
const result = rematerialize(spec, spec, stored, { changeInstant: "2024-03-01T00:00:00Z" });
|
||||
expect(result.toDeleteFireAtUtc).toEqual([Date.parse("2024-04-01T09:00:00Z")]);
|
||||
});
|
||||
|
||||
it("survives a whole-day skip: Apia daily 09:00 across the 2011 change", () => {
|
||||
const spec = { cron: "0 9 * * *", timezone: "Pacific/Apia" };
|
||||
const stored = storedFrom(spec, "2011-12-28T00:00:00Z", 3);
|
||||
const result = rematerialize(spec, spec, stored, {
|
||||
changeInstant: "2011-12-29T12:00:00Z",
|
||||
lastFiredNominalLocal: "2011-12-28T09:00",
|
||||
count: 2,
|
||||
});
|
||||
expect(result.toInsert.map((o) => o.nominalLocal)).toEqual([
|
||||
"2011-12-29T09:00",
|
||||
"2011-12-30T09:00", // skipped day, shifted forward
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rematerialize — input validation", () => {
|
||||
it("rejects an invalid new zone", () => {
|
||||
const spec = { cron: "0 9 * * *", timezone: "UTC" };
|
||||
expect(() =>
|
||||
rematerialize(spec, { cron: "0 9 * * *", timezone: "local" }, [], { changeInstant: "2024-01-01T00:00:00Z" }),
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects an invalid new cron", () => {
|
||||
const spec = { cron: "0 9 * * *", timezone: "UTC" };
|
||||
expect(() =>
|
||||
rematerialize(spec, { cron: "99 * * * *", timezone: "UTC" }, [], { changeInstant: "2024-01-01T00:00:00Z" }),
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects an offset-less change instant", () => {
|
||||
const spec = { cron: "0 9 * * *", timezone: "UTC" };
|
||||
expect(() => rematerialize(spec, spec, [], { changeInstant: "2024-01-01T00:00:00" })).toThrow();
|
||||
});
|
||||
});
|
||||
121
packages/scheduling/src/rematerialize.ts
Normal file
121
packages/scheduling/src/rematerialize.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { nextOccurrencesPrepared } from "./occurrences.js";
|
||||
import { prepareSchedule, type Occurrence, type ScheduleSpec } from "./schedule.js";
|
||||
import { currentTzdataVersion, toInstantMs } from "./timezone.js";
|
||||
import type { NominalLocal } from "./timezone.js";
|
||||
|
||||
/** A stored occurrence row, as read from `schedule_occurrences`. */
|
||||
export interface StoredOccurrence {
|
||||
/** Nominal local slot of this row (e.g. `2024-03-10T09:00`). */
|
||||
readonly nominalLocal: NominalLocal;
|
||||
/** Materialized UTC fire instant, epoch ms. */
|
||||
readonly fireAtUtc: number;
|
||||
/** Whether a claim has been taken on this row. */
|
||||
readonly claimed: boolean;
|
||||
}
|
||||
|
||||
export interface RematerializeOptions {
|
||||
/**
|
||||
* Instant the zone/tzdata change was applied (epoch ms). New occurrences
|
||||
* must fire strictly after max(this, last claimed fire instant).
|
||||
*/
|
||||
readonly changeInstant: Date | number | string;
|
||||
/**
|
||||
* `last_fired_nominal_local` recorded on the schedule — the local slot of
|
||||
* the last claimed occurrence. New occurrences must be in a strictly later
|
||||
* nominal slot, so a slot already fired under the old zone never refires.
|
||||
*/
|
||||
readonly lastFiredNominalLocal?: NominalLocal;
|
||||
/** How many future occurrences to generate after the change. */
|
||||
readonly count?: number;
|
||||
/** Override the recorded tzdata version in tests. */
|
||||
readonly tzdataVersion?: string;
|
||||
}
|
||||
|
||||
export interface RematerializeResult {
|
||||
/** Occurrences to insert, ascending by fire instant. */
|
||||
readonly toInsert: Occurrence[];
|
||||
/** Fire instants (epoch ms) of unclaimed future rows to delete. */
|
||||
readonly toDeleteFireAtUtc: number[];
|
||||
/** The tzdata version backing this materialization, to record on the schedule. */
|
||||
readonly tzdataVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-materialize a schedule after a tzdata or timezone change.
|
||||
*
|
||||
* Order of operations mirrors the spec: first, all *unclaimed* future
|
||||
* occurrences are invalidated (their fire instants are returned for deletion);
|
||||
* then new occurrences are generated — only those whose fire instant is
|
||||
* strictly after max(change instant, last claimed fire instant) AND whose
|
||||
* nominal local slot is strictly later than `last_fired_nominal_local`, so a
|
||||
* slot already fired under the old zone (daily 09:00 fired in New York, then
|
||||
* zone switched to Los Angeles) is never fired again.
|
||||
*
|
||||
* Claimed rows are never deleted and never regenerate: their fire instants
|
||||
* already happened or are owned by a claim.
|
||||
*/
|
||||
export function rematerialize(
|
||||
oldSpec: ScheduleSpec,
|
||||
newSpec: ScheduleSpec,
|
||||
stored: readonly StoredOccurrence[],
|
||||
options: RematerializeOptions,
|
||||
): RematerializeResult {
|
||||
const changeMs = toInstantMs(options.changeInstant);
|
||||
const lastFiredNominal = options.lastFiredNominalLocal;
|
||||
const tzVersion = options.tzdataVersion ?? currentTzdataVersion();
|
||||
|
||||
// 1) Invalidate every unclaimed future occurrence: its fire instant is
|
||||
// returned for deletion. Claimed rows survive untouched.
|
||||
const toDeleteFireAtUtc: number[] = [];
|
||||
let lastClaimedFireMs: number | null = null;
|
||||
let maxClaimedNominal: NominalLocal | null = null;
|
||||
for (const row of stored) {
|
||||
if (row.claimed) {
|
||||
if (lastClaimedFireMs === null || row.fireAtUtc > lastClaimedFireMs) {
|
||||
lastClaimedFireMs = row.fireAtUtc;
|
||||
maxClaimedNominal = row.nominalLocal;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (row.fireAtUtc > changeMs) {
|
||||
toDeleteFireAtUtc.push(row.fireAtUtc);
|
||||
}
|
||||
}
|
||||
|
||||
// Effective refire gate: max(change instant, last claimed fire instant).
|
||||
const gateMs = Math.max(changeMs, lastClaimedFireMs ?? Number.NEGATIVE_INFINITY);
|
||||
// Effective nominal gate: the later of the recorded last-fired slot and the
|
||||
// latest claimed row's slot (rows are the runtime truth).
|
||||
let nominalGate: NominalLocal | null = lastFiredNominal ?? null;
|
||||
if (maxClaimedNominal !== null && (nominalGate === null || maxClaimedNominal > nominalGate)) {
|
||||
nominalGate = maxClaimedNominal;
|
||||
}
|
||||
|
||||
// 2) Generate forward from the gate, skipping any slot not strictly later
|
||||
// than the nominal gate.
|
||||
const count = options.count ?? 32;
|
||||
const prepared = prepareSchedule(newSpec);
|
||||
const candidates = nextOccurrencesPrepared(
|
||||
prepared,
|
||||
gateMs,
|
||||
count * 4,
|
||||
{ exclusive: true },
|
||||
);
|
||||
// Only rows that SURVIVE the change may block regeneration: claimed rows and
|
||||
// unclaimed rows already in the past. Rows being deleted are being replaced,
|
||||
// so their instants must not suppress the regenerated occurrences.
|
||||
const deleting = new Set(toDeleteFireAtUtc);
|
||||
const existing = new Set(
|
||||
stored.filter((row) => !deleting.has(row.fireAtUtc)).map((row) => row.fireAtUtc),
|
||||
);
|
||||
const toInsert: Occurrence[] = [];
|
||||
for (const occurrence of candidates) {
|
||||
if (occurrence.fireAtUtc <= gateMs) continue;
|
||||
if (nominalGate !== null && occurrence.nominalLocal <= nominalGate) continue;
|
||||
if (existing.has(occurrence.fireAtUtc)) continue;
|
||||
toInsert.push(occurrence);
|
||||
if (toInsert.length >= count) break;
|
||||
}
|
||||
toInsert.sort((a, b) => a.fireAtUtc - b.fireAtUtc);
|
||||
return { toInsert, toDeleteFireAtUtc, tzdataVersion: tzVersion };
|
||||
}
|
||||
96
packages/scheduling/src/schedule.ts
Normal file
96
packages/scheduling/src/schedule.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { cronMatchesDay, type ParsedCron } from "./cron.js";
|
||||
import { formatNominal } from "./nominal.js";
|
||||
import { resolveWallToInstant } from "./materialize.js";
|
||||
import { canonicalTimezone, wallOfInstant } from "./timezone.js";
|
||||
import { parseCron } from "./cron.js";
|
||||
import type { NominalLocal } from "./timezone.js";
|
||||
|
||||
/** A schedule's time specification: five-field cron in an explicit IANA zone. */
|
||||
export interface ScheduleSpec {
|
||||
/** Five-field cron expression, e.g. `*\/15 * * * *`. */
|
||||
readonly cron: string;
|
||||
/** Explicit IANA zone id — never server-local. `UTC` is allowed. */
|
||||
readonly timezone: string;
|
||||
}
|
||||
|
||||
/** A materialized occurrence: nominal local slot plus its UTC fire instant. */
|
||||
export interface Occurrence {
|
||||
/** Local date-time of this slot in the schedule zone, e.g. `2024-03-10T02:30`. */
|
||||
nominalLocal: NominalLocal;
|
||||
/** Materialized UTC fire instant in epoch milliseconds. */
|
||||
fireAtUtc: number;
|
||||
}
|
||||
|
||||
/** A schedule validated once, reusable across calls. */
|
||||
export interface PreparedSchedule {
|
||||
readonly cron: ParsedCron;
|
||||
readonly timezone: string;
|
||||
readonly spec: ScheduleSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a spec (cron + zone) once. Throws `InvalidCronError`,
|
||||
* `ImpossibleScheduleError` or `InvalidTimezoneError` on bad input.
|
||||
*/
|
||||
export function prepareSchedule(spec: ScheduleSpec): PreparedSchedule {
|
||||
return {
|
||||
cron: parseCron(spec.cron),
|
||||
timezone: canonicalTimezone(spec.timezone),
|
||||
spec: { cron: spec.cron, timezone: spec.timezone },
|
||||
};
|
||||
}
|
||||
|
||||
/** Hour values sorted ascending for deterministic day-slot iteration. */
|
||||
export function sortedHours(cron: ParsedCron): number[] {
|
||||
return [...cron.hour.values].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Builds the nominal-local string for one slot on a matching local day. */
|
||||
export function slotNominal(year: number, month: number, day: number, hour: number, minute: number): NominalLocal {
|
||||
const y = String(year).padStart(4, "0");
|
||||
const mo = String(month).padStart(2, "0");
|
||||
const d = String(day).padStart(2, "0");
|
||||
const h = String(hour).padStart(2, "0");
|
||||
const mi = String(minute).padStart(2, "0");
|
||||
return `${y}-${mo}-${d}T${h}:${mi}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate matching local days at or after a starting local day. Returns each
|
||||
* matching day's wall clock at local midnight (materialized to an instant so
|
||||
* the caller can step by absolute time across DST).
|
||||
*/
|
||||
export function* matchingLocalDays(
|
||||
cron: ParsedCron,
|
||||
startDayEpoch: number,
|
||||
zone: string,
|
||||
maxDays: number,
|
||||
): Generator<{ wall: ReturnType<typeof wallOfInstant>; midnightInstant: number }> {
|
||||
let cursor = startDayEpoch;
|
||||
for (let i = 0; i < maxDays; i++) {
|
||||
const wall = wallOfInstant(cursor, zone);
|
||||
if (cronMatchesDay(cron, wall.year, wall.month, wall.day)) {
|
||||
yield { wall, midnightInstant: cursor };
|
||||
}
|
||||
cursor = stepOneLocalDay(cursor, zone);
|
||||
}
|
||||
}
|
||||
|
||||
/** Advance to the next local midnight, robust across 23/24/25-hour days. */
|
||||
export function stepOneLocalDay(dayEpoch: number, zone: string): number {
|
||||
const nextApprox = dayEpoch + 24 * 3600_000;
|
||||
const nextWall = wallOfInstant(nextApprox, zone);
|
||||
return resolveWallToInstant(
|
||||
{ year: nextWall.year, month: nextWall.month, day: nextWall.day, hour: 0, minute: 0, second: 0 },
|
||||
zone,
|
||||
);
|
||||
}
|
||||
|
||||
export { formatNominal };
|
||||
|
||||
/** Minute values sorted ascending for deterministic day-slot iteration. */
|
||||
export function sortedMinutes(cron: ParsedCron): number[] {
|
||||
return [...cron.minute.values].sort((a, b) => a - b);
|
||||
}
|
||||
92
packages/scheduling/src/timezone.test.ts
Normal file
92
packages/scheduling/src/timezone.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canonicalTimezone,
|
||||
currentTzdataVersion,
|
||||
isValidTimezone,
|
||||
offsetMs,
|
||||
toInstantMs,
|
||||
wallOfInstant,
|
||||
} from "./timezone.js";
|
||||
import { InvalidInstantError, InvalidTimezoneError } from "./errors.js";
|
||||
|
||||
describe("canonicalTimezone", () => {
|
||||
it("accepts and canonicalizes IANA ids", () => {
|
||||
expect(canonicalTimezone("America/New_York")).toBe("America/New_York");
|
||||
expect(canonicalTimezone("US/Eastern")).toBe("America/New_York");
|
||||
expect(canonicalTimezone(" Europe/Paris ")).toBe("Europe/Paris");
|
||||
expect(canonicalTimezone("Etc/GMT+5")).toBe("Etc/GMT+5");
|
||||
});
|
||||
it("accepts UTC in several spellings", () => {
|
||||
expect(canonicalTimezone("UTC")).toBe("UTC");
|
||||
expect(canonicalTimezone("gmt")).toBe("UTC");
|
||||
expect(canonicalTimezone("Zulu")).toBe("UTC");
|
||||
});
|
||||
it("rejects unknown zones", () => {
|
||||
expect(() => canonicalTimezone("America/Nowhere")).toThrow(InvalidTimezoneError);
|
||||
expect(() => canonicalTimezone("Mars/Olympus")).toThrow(InvalidTimezoneError);
|
||||
});
|
||||
it("rejects offset spellings and empty input", () => {
|
||||
for (const bad of ["UTC+3", "+05:30", "-08:00", "5", ""]) {
|
||||
expect(() => canonicalTimezone(bad)).toThrow(InvalidTimezoneError);
|
||||
}
|
||||
});
|
||||
it("rejects server-local idioms — a schedule zone must be explicit", () => {
|
||||
for (const bad of ["local", "system", "server", "SERVER", "server-local", "machine"]) {
|
||||
expect(() => canonicalTimezone(bad)).toThrow(InvalidTimezoneError);
|
||||
}
|
||||
});
|
||||
it("isValidTimezone mirrors canonicalTimezone", () => {
|
||||
expect(isValidTimezone("Europe/Berlin")).toBe(true);
|
||||
expect(isValidTimezone("local")).toBe(false);
|
||||
expect(isValidTimezone("America/Nowhere")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wallOfInstant / offsetMs", () => {
|
||||
it("reads New York offsets across the 2024 transitions", () => {
|
||||
expect(offsetMs(Date.parse("2024-03-10T06:59:59Z"), "America/New_York")).toBe(-5 * 3600_000);
|
||||
expect(offsetMs(Date.parse("2024-03-10T07:00:00Z"), "America/New_York")).toBe(-4 * 3600_000);
|
||||
expect(offsetMs(Date.parse("2024-11-03T05:59:59Z"), "America/New_York")).toBe(-4 * 3600_000);
|
||||
expect(offsetMs(Date.parse("2024-11-03T06:00:00Z"), "America/New_York")).toBe(-5 * 3600_000);
|
||||
});
|
||||
it("reads half-hour and 45-minute zones", () => {
|
||||
expect(offsetMs(Date.parse("2024-06-01T12:00:00Z"), "Asia/Kolkata")).toBe(5.5 * 3600_000);
|
||||
expect(offsetMs(Date.parse("2024-06-01T12:00:00Z"), "Asia/Kathmandu")).toBe(5.75 * 3600_000);
|
||||
});
|
||||
it("sees Pacific/Apia's 2011 whole-day skip", () => {
|
||||
const before = wallOfInstant(Date.parse("2011-12-29T09:59:59Z"), "Pacific/Apia");
|
||||
const after = wallOfInstant(Date.parse("2011-12-30T10:00:00Z"), "Pacific/Apia");
|
||||
expect(`${before.year}-${before.month}-${before.day}`).toBe("2011-12-28");
|
||||
expect(`${after.year}-${after.month}-${after.day}`).toBe("2011-12-31");
|
||||
});
|
||||
it("returns UTC fields without a formatter", () => {
|
||||
const wall = wallOfInstant(Date.parse("2024-03-10T02:30:00Z"), "UTC");
|
||||
expect([wall.year, wall.month, wall.day, wall.hour, wall.minute]).toEqual([2024, 3, 10, 2, 30]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toInstantMs", () => {
|
||||
it("accepts dates, numbers and offset-carrying ISO strings", () => {
|
||||
expect(toInstantMs(new Date("2024-03-10T02:30:00Z"))).toBe(Date.parse("2024-03-10T02:30:00Z"));
|
||||
expect(toInstantMs(1710038400000)).toBe(1710038400000);
|
||||
expect(toInstantMs("2024-03-10T02:30:00Z")).toBe(Date.parse("2024-03-10T02:30:00Z"));
|
||||
expect(toInstantMs("2024-03-10T04:30:00+02:00")).toBe(Date.parse("2024-03-10T02:30:00Z"));
|
||||
});
|
||||
it("rejects offset-less datetimes that would parse server-local", () => {
|
||||
for (const bad of ["2024-03-10T02:30:00", "2024-03-10 02:30", "2024-03-10T02:30:00.500"]) {
|
||||
expect(() => toInstantMs(bad)).toThrow(InvalidInstantError);
|
||||
}
|
||||
});
|
||||
it("rejects NaN/Infinity/garbage", () => {
|
||||
expect(() => toInstantMs(new Date(NaN))).toThrow(InvalidInstantError);
|
||||
expect(() => toInstantMs(Number.NaN)).toThrow(InvalidInstantError);
|
||||
expect(() => toInstantMs(Number.POSITIVE_INFINITY)).toThrow(InvalidInstantError);
|
||||
expect(() => toInstantMs("not a date")).toThrow(InvalidInstantError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentTzdataVersion", () => {
|
||||
it("returns a non-empty version string", () => {
|
||||
expect(currentTzdataVersion()).toMatch(/.+/);
|
||||
});
|
||||
});
|
||||
212
packages/scheduling/src/timezone.ts
Normal file
212
packages/scheduling/src/timezone.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { InvalidInstantError, InvalidTimezoneError } from "./errors.js";
|
||||
|
||||
/**
|
||||
* Wall-clock reading of an instant in some zone. Fields are the raw numeric
|
||||
* calendar values (month 1-12, day 1-31, hour 0-23).
|
||||
*/
|
||||
export interface WallClock {
|
||||
readonly year: number;
|
||||
readonly month: number;
|
||||
readonly day: number;
|
||||
readonly hour: number;
|
||||
readonly minute: number;
|
||||
readonly second: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nominal local date-time, e.g. `2024-03-10T02:30`. Lexicographic order of
|
||||
* these strings is chronological order, which is what the
|
||||
* `last_fired_nominal_local` refire gate relies on.
|
||||
*/
|
||||
export type NominalLocal = string;
|
||||
|
||||
const WALL_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
};
|
||||
|
||||
const wallFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function wallFormatter(zone: string): Intl.DateTimeFormat {
|
||||
const cached = wallFormatters.get(zone);
|
||||
if (cached) return cached;
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
...WALL_FORMAT_OPTIONS,
|
||||
timeZone: zone,
|
||||
});
|
||||
wallFormatters.set(zone, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
const IDIOMS_REJECTED = new Set(["local", "system", "server", "server-local", "machine"]);
|
||||
|
||||
/**
|
||||
* Validate a timezone and return its canonical IANA id.
|
||||
*
|
||||
* Accepts real IANA ids (including `Etc/GMT+5` style fixed zones and `UTC`),
|
||||
* canonicalizing whatever Intl resolves (e.g. `US/Eastern` -> `America/New_York`).
|
||||
* Rejects unknown ids, offset spellings like `UTC+3`/`+05:30`, empty input and
|
||||
* the strings `local`/`system` — the schedule zone is never allowed to fall
|
||||
* back to the process zone.
|
||||
*/
|
||||
export function canonicalTimezone(timezone: string): string {
|
||||
if (typeof timezone !== "string") {
|
||||
throw new InvalidTimezoneError(`timezone must be a string, got ${typeof timezone}`);
|
||||
}
|
||||
const trimmed = timezone.trim();
|
||||
if (trimmed === "") {
|
||||
throw new InvalidTimezoneError("timezone must be an explicit IANA zone id, not an empty string");
|
||||
}
|
||||
if (IDIOMS_REJECTED.has(trimmed.toLowerCase())) {
|
||||
throw new InvalidTimezoneError(
|
||||
`timezone "${timezone}" is not an IANA zone id; a schedule must carry an explicit zone (never server-local)`,
|
||||
);
|
||||
}
|
||||
if (/^[+-]?\d{1,2}(:?\d{2})?$/.test(trimmed) || /^UTC[+-]\d/.test(trimmed)) {
|
||||
throw new InvalidTimezoneError(
|
||||
`timezone "${timezone}" is a UTC offset, not an IANA zone id; store an IANA zone (UTC is allowed)`,
|
||||
);
|
||||
}
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).resolvedOptions().timeZone;
|
||||
} catch {
|
||||
throw new InvalidTimezoneError(`unknown IANA time zone: "${timezone}"`);
|
||||
}
|
||||
if (!resolved) {
|
||||
throw new InvalidTimezoneError(`unknown IANA time zone: "${timezone}"`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** True when `canonicalTimezone` would accept the value. */
|
||||
export function isValidTimezone(timezone: string): boolean {
|
||||
try {
|
||||
canonicalTimezone(timezone);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate and canonicalize, throwing `InvalidTimezoneError` on rejection. */
|
||||
export function assertValidTimezone(timezone: string): string {
|
||||
return canonicalTimezone(timezone);
|
||||
}
|
||||
|
||||
/** Local wall clock struck by an instant, in the given zone. */
|
||||
export function wallOfInstant(epochMs: number, zone: string): WallClock {
|
||||
if (zone === "UTC") {
|
||||
return decodeUtc(epochMs);
|
||||
}
|
||||
const parts = wallFormatter(zone).formatToParts(new Date(epochMs));
|
||||
let year = 0;
|
||||
let month = 0;
|
||||
let day = 0;
|
||||
let hour = 0;
|
||||
let minute = 0;
|
||||
let second = 0;
|
||||
for (const part of parts) {
|
||||
switch (part.type) {
|
||||
case "year":
|
||||
year = Number(part.value);
|
||||
break;
|
||||
case "month":
|
||||
month = Number(part.value);
|
||||
break;
|
||||
case "day":
|
||||
day = Number(part.value);
|
||||
break;
|
||||
case "hour":
|
||||
hour = Number(part.value);
|
||||
break;
|
||||
case "minute":
|
||||
minute = Number(part.value);
|
||||
break;
|
||||
case "second":
|
||||
second = Number(part.value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { year, month, day, hour, minute, second };
|
||||
}
|
||||
|
||||
/** The UTC instant a wall-clock reading would denote if the offset were zero. */
|
||||
export function epochOfWall(wall: WallClock): number {
|
||||
return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second);
|
||||
}
|
||||
|
||||
function decodeUtc(epochMs: number): WallClock {
|
||||
const d = new Date(epochMs);
|
||||
return {
|
||||
year: d.getUTCFullYear(),
|
||||
month: d.getUTCMonth() + 1,
|
||||
day: d.getUTCDate(),
|
||||
hour: d.getUTCHours(),
|
||||
minute: d.getUTCMinutes(),
|
||||
second: d.getUTCSeconds(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone offset (ms east of UTC) in effect at an instant. Minute-precision
|
||||
* arithmetic against `Intl`; UTC short-circuits without touching a formatter.
|
||||
*/
|
||||
export function offsetMs(epochMs: number, zone: string): number {
|
||||
if (zone === "UTC") return 0;
|
||||
return epochOfWall(wallOfInstant(epochMs, zone)) - epochMs;
|
||||
}
|
||||
|
||||
const OFFSET_ISO_TAIL = /(?:Z|[+-]\d{2}:\d{2})$/;
|
||||
|
||||
/**
|
||||
* Coerce an instant input to epoch milliseconds.
|
||||
*
|
||||
* `Date` and finite numbers pass through. Strings must be ISO-8601; a string
|
||||
* that carries a time component must also carry an explicit UTC offset (`Z` or
|
||||
* `±hh:mm`) — otherwise `Date.parse` would silently apply the server's local
|
||||
* zone, which this library exists to prevent.
|
||||
*/
|
||||
export function toInstantMs(value: Date | number | string): number {
|
||||
if (value instanceof Date) {
|
||||
const ms = value.getTime();
|
||||
if (Number.isNaN(ms)) throw new InvalidInstantError("invalid Date (NaN)");
|
||||
return ms;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new InvalidInstantError(`invalid instant number: ${value}`);
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
const hasTime = /[T ]\d/.test(trimmed);
|
||||
if (hasTime && !OFFSET_ISO_TAIL.test(trimmed)) {
|
||||
throw new InvalidInstantError(
|
||||
`instant "${value}" has a time but no UTC offset; supply ...Z or ...±hh:mm (never server-local)`,
|
||||
);
|
||||
}
|
||||
const ms = Date.parse(trimmed);
|
||||
if (Number.isNaN(ms)) throw new InvalidInstantError(`unparseable instant: "${value}"`);
|
||||
return ms;
|
||||
}
|
||||
throw new InvalidInstantError(`unsupported instant input: ${String(value)}`);
|
||||
}
|
||||
|
||||
type ProcessVersionsLike = { tz?: string };
|
||||
|
||||
/**
|
||||
* tzdata version backing this process (`process.versions.tz`, e.g. `2026a`),
|
||||
* or `"unknown"` outside Node. Recorded per schedule so tzdata-driven
|
||||
* re-materialization can detect drift.
|
||||
*/
|
||||
export function currentTzdataVersion(): string {
|
||||
const proc = (globalThis as { process?: { versions?: ProcessVersionsLike } }).process;
|
||||
return proc?.versions?.tz ?? "unknown";
|
||||
}
|
||||
27
packages/scheduling/src/verify.test.ts
Normal file
27
packages/scheduling/src/verify.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { nextOccurrences } from "./occurrences_extra.js";
|
||||
|
||||
const ISO = (o: { fireAtUtc: number }) => new Date(o.fireAtUtc).toISOString().replace(".000Z", "Z");
|
||||
|
||||
describe("count boundary semantics", () => {
|
||||
it("never returns more than count", () => {
|
||||
for (const count of [1, 2, 5, 17]) {
|
||||
const out = nextOccurrences({ cron: "*/15 * * * *", timezone: "America/New_York" }, "2024-03-09T00:00:00Z", count);
|
||||
expect(out.length).toBe(count);
|
||||
}
|
||||
});
|
||||
it("stops at the collapse edge with the latest collapsed label", () => {
|
||||
// Exclusive from 05:00Z (== 00:00 local): the first slot is 00:15. The
|
||||
// eighth occurrence is the collapse edge — nominal slots 02:00..03:00 all
|
||||
// materialize onto 07:00Z — and it carries the LATEST label (03:00), with
|
||||
// nothing beyond the edge emitted.
|
||||
const out = nextOccurrences({ cron: "*/15 * * * *", timezone: "America/New_York" }, "2024-03-10T05:00:00Z", 8);
|
||||
expect(out.length).toBe(8);
|
||||
expect(out.map(ISO)[7]).toBe("2024-03-10T07:00:00Z");
|
||||
expect(out[7]!.nominalLocal).toBe("2024-03-10T03:00");
|
||||
// The same result whether the boundary is reached with 8 or 80 requested,
|
||||
// because generation stops at the first slot after the boundary instant.
|
||||
const wide = nextOccurrences({ cron: "*/15 * * * *", timezone: "America/New_York" }, "2024-03-10T05:00:00Z", 80);
|
||||
expect(wide[7]!.nominalLocal).toBe("2024-03-10T03:00");
|
||||
});
|
||||
});
|
||||
8
packages/scheduling/tsconfig.json
Normal file
8
packages/scheduling/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue