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:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View 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`.

View 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"
}
}

View 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);
});
});

View 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);
}

View 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 {}

View 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";

View 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);
}

View 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 {};

View 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;
}

View 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);
}

View 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();
});
});

View 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;
}

View 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);
}

View 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);
});
});

View 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();
});
});

View 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 };
}

View 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);
}

View 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(/.+/);
});
});

View 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";
}

View 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");
});
});

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}