Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
export type { WallClock } from "./timezone.js";
export {
assertTimezone,
detectDstTransition,
fromWallClock,
toWallClock,
} from "./timezone.js";

View File

@@ -0,0 +1,88 @@
# timezone
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The timezone module converts between epoch-millisecond timestamps and the
wall-clock components observed in a specific IANA timezone using only the
platform-native `Intl.DateTimeFormat` — no external timezone database is
shipped or required. It powers the cron next-run algorithm by translating
the user's "every day at 02:00 New York time" into the actual moments
when that wall-clock instant occurs, including the awkward edge cases of
daylight-saving-time spring-forward gaps and fall-back overlaps.
## Features
- `toWallClock(epochMs, tz)` — decompose an epoch instant into year /
month / day / hour / minute / second / weekday in the requested zone.
- `fromWallClock(components, tz)` — the inverse; performs up to four
bounded fixed-point iterations to converge on the correct epoch even
across DST transitions.
- `detectDstTransition(before, after, tz)` — classifies a transition
between two adjacent instants as `"spring-forward"`, `"fall-back"`, or
`null` when no transition is crossed.
- `assertTimezone(tz)` — eagerly validates an IANA name so the caller can
fail fast at the API boundary.
- Per-zone formatter cache keeps Intl construction off the hot path.
## Usage: Basic Example
```typescript
import { toWallClock, fromWallClock } from "@suaveplan/cron";
const wall = toWallClock(Date.UTC(2026, 0, 1, 5, 0, 0), "America/New_York");
console.log(wall.hour); // 0 — midnight in NY
const back = fromWallClock(
{ year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 },
"America/New_York"
);
console.log(back === Date.UTC(2026, 0, 1, 5, 0, 0)); // true
```
## Usage: Advanced Example
```typescript
import {
detectDstTransition,
fromWallClock,
toWallClock,
} from "@suaveplan/cron";
const start = Date.UTC(2026, 2, 8, 6, 0, 0);
const end = Date.UTC(2026, 2, 8, 8, 0, 0);
const transition = detectDstTransition(start, end, "America/New_York");
console.log(transition); // "spring-forward"
const requested = { year: 2026, month: 3, day: 8, hour: 2, minute: 30, second: 0 };
const real = fromWallClock(requested, "America/New_York");
const observed = toWallClock(real, "America/New_York");
console.log(observed.hour, observed.minute); // not 02:30 — the slot was skipped
```
## API Reference
- `interface WallClock` — `{ year, month, day, hour, minute, second, weekday }`
with `weekday ∈ 0..6` (Sunday = 0, matching cron numbering).
- `toWallClock(epochMs: number, tz: string): WallClock`.
- `fromWallClock(components: Omit<WallClock, "weekday">, tz: string): number`.
- `detectDstTransition(beforeEpochMs, afterEpochMs, tz):
"spring-forward" | "fall-back" | null`.
- `assertTimezone(tz: string): void` — throws
`CronError(CRON.TIMEZONE_INVALID)` for invalid IANA names.
## Implementation Notes
`Intl.DateTimeFormat` is the only timezone authority. The package never
ships a tz database — keeping it universal and dependency-free. The
inverse `fromWallClock` works by guessing UTC, observing the wall clock
offset that produces, and applying it back. Two iterations suffice for
every IANA zone in normal time; we run up to four to handle the awkward
spring-forward case where the first correction overshoots the gap.
Inside the gap the function picks the canonical real-instant that
`Intl.DateTimeFormat` reports, and callers re-verify against their field
set so cron schedules that fall in the gap fire at the next existing
minute. Formatter construction is cached per IANA name to keep the hot
path allocation-free.

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRON_CODES, CronError } from "../errors/cron-error.js";
import {
assertTimezone,
detectDstTransition,
fromWallClock,
toWallClock,
} from "./timezone.js";
describe("toWallClock", () => {
it("decomposes UTC epoch to UTC components", { timeout: 5000 }, () => {
const wall = toWallClock(Date.UTC(2026, 4, 11, 10, 30, 15), "UTC");
expect(wall.year).toBe(2026);
expect(wall.month).toBe(5);
expect(wall.day).toBe(11);
expect(wall.hour).toBe(10);
expect(wall.minute).toBe(30);
expect(wall.second).toBe(15);
});
it("applies America/New_York offset", { timeout: 5000 }, () => {
const wall = toWallClock(
Date.UTC(2026, 0, 1, 5, 0, 0),
"America/New_York"
);
expect(wall.year).toBe(2026);
expect(wall.month).toBe(1);
expect(wall.day).toBe(1);
expect(wall.hour).toBe(0);
});
it("returns 0..6 weekday", { timeout: 5000 }, () => {
// 2026-05-11 is a Monday.
const wall = toWallClock(Date.UTC(2026, 4, 11, 12, 0, 0), "UTC");
expect(wall.weekday).toBe(1);
});
it("throws TIMEZONE_INVALID for bad IANA name", { timeout: 5000 }, () => {
try {
toWallClock(0, "Not/A_Zone");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(CronError);
expect((err as CronError).code).toBe(CRON_CODES.TIMEZONE_INVALID);
}
});
});
describe("fromWallClock", () => {
it("round-trips UTC components", { timeout: 5000 }, () => {
const wall = {
year: 2026,
month: 5,
day: 11,
hour: 10,
minute: 30,
second: 0,
};
const epoch = fromWallClock(wall, "UTC");
expect(epoch).toBe(Date.UTC(2026, 4, 11, 10, 30, 0));
});
it("round-trips America/New_York components (winter)", {
timeout: 5000,
}, () => {
const wall = {
year: 2026,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
};
const epoch = fromWallClock(wall, "America/New_York");
// NY in January = UTC-5.
expect(epoch).toBe(Date.UTC(2026, 0, 1, 5, 0, 0));
});
it("round-trips America/New_York components (summer)", {
timeout: 5000,
}, () => {
const wall = {
year: 2026,
month: 7,
day: 1,
hour: 12,
minute: 0,
second: 0,
};
const epoch = fromWallClock(wall, "America/New_York");
// NY in July = UTC-4.
expect(epoch).toBe(Date.UTC(2026, 6, 1, 16, 0, 0));
});
it("converges across the spring-forward gap", { timeout: 5000 }, () => {
// 2026-03-08 02:30 local does not exist in America/New_York —
// clock jumps from 02:00 to 03:00. The function returns _some_
// real instant; callers re-verify the resulting wall clock.
const wall = {
year: 2026,
month: 3,
day: 8,
hour: 2,
minute: 30,
second: 0,
};
const epoch = fromWallClock(wall, "America/New_York");
expect(Number.isFinite(epoch)).toBe(true);
});
});
describe("detectDstTransition", () => {
it("detects spring-forward in America/New_York", { timeout: 5000 }, () => {
const before = Date.UTC(2026, 2, 8, 6, 0, 0); // 01:00 local EST
const after = Date.UTC(2026, 2, 8, 8, 0, 0); // 04:00 local EDT
expect(detectDstTransition(before, after, "America/New_York")).toBe(
"spring-forward"
);
});
it("detects fall-back in America/New_York", { timeout: 5000 }, () => {
const before = Date.UTC(2026, 10, 1, 5, 0, 0); // 01:00 EDT
const after = Date.UTC(2026, 10, 1, 7, 0, 0); // 02:00 EST
expect(detectDstTransition(before, after, "America/New_York")).toBe(
"fall-back"
);
});
it("returns null when no transition crossed", { timeout: 5000 }, () => {
const a = Date.UTC(2026, 4, 11, 10, 0, 0);
const b = Date.UTC(2026, 4, 11, 11, 0, 0);
expect(detectDstTransition(a, b, "America/New_York")).toBeNull();
});
});
describe("assertTimezone", () => {
it("accepts known zones", { timeout: 5000 }, () => {
expect(() => assertTimezone("UTC")).not.toThrow();
expect(() => assertTimezone("Europe/London")).not.toThrow();
expect(() => assertTimezone("Australia/Sydney")).not.toThrow();
});
it("rejects unknown zones", { timeout: 5000 }, () => {
expect(() => assertTimezone("Mars/Olympus_Mons")).toThrow(CronError);
});
});

View File

@@ -0,0 +1,171 @@
import { CRON_CODES, CronError } from "../errors/cron-error.js";
/** Wall-clock components in a specific IANA timezone. */
export interface WallClock {
readonly year: number;
readonly month: number;
readonly day: number;
readonly hour: number;
readonly minute: number;
readonly second: number;
/** 0 = Sunday … 6 = Saturday — matches cron day-of-week numbering. */
readonly weekday: number;
}
const TZ_FORMAT_CACHE = new Map<string, Intl.DateTimeFormat>();
function getFormatter(tz: string): Intl.DateTimeFormat {
const cached = TZ_FORMAT_CACHE.get(tz);
if (cached !== undefined) return cached;
let fmt: Intl.DateTimeFormat;
try {
fmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
weekday: "short",
});
} catch (cause) {
throw new CronError(
CRON_CODES.TIMEZONE_INVALID,
"cron.timezone_invalid",
{ context: { tz }, cause: cause as Error }
);
}
TZ_FORMAT_CACHE.set(tz, fmt);
return fmt;
}
const WEEKDAY_MAP: Readonly<Record<string, number>> = Object.freeze({
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
});
/**
* Convert an epoch millisecond timestamp to its wall-clock components in
* `tz`. Throws `CronError(CRON.TIMEZONE_INVALID)` when the IANA name is
* rejected by the platform `Intl.DateTimeFormat`.
*/
export function toWallClock(epochMs: number, tz: string): WallClock {
const fmt = getFormatter(tz);
const parts = fmt.formatToParts(new Date(epochMs));
let year = 0;
let month = 0;
let day = 0;
let hour = 0;
let minute = 0;
let second = 0;
let weekday = 0;
for (const part of parts) {
switch (part.type) {
case "year":
year = Number.parseInt(part.value, 10);
break;
case "month":
month = Number.parseInt(part.value, 10);
break;
case "day":
day = Number.parseInt(part.value, 10);
break;
case "hour":
// Intl emits "24" for midnight at the tail end of some hourCycle
// boundaries; normalize to 0.
hour = Number.parseInt(part.value, 10) % 24;
break;
case "minute":
minute = Number.parseInt(part.value, 10);
break;
case "second":
second = Number.parseInt(part.value, 10);
break;
case "weekday":
// Intl always emits one of Sun..Sat for the `short` weekday
// formatter we configured.
weekday = WEEKDAY_MAP[part.value] as number;
break;
default:
break;
}
}
return { year, month, day, hour, minute, second, weekday };
}
function utcEpoch(components: Omit<WallClock, "weekday">): number {
return Date.UTC(
components.year,
components.month - 1,
components.day,
components.hour,
components.minute,
components.second
);
}
function offsetMs(epochMs: number, tz: string): number {
const wall = toWallClock(epochMs, tz);
return utcEpoch(wall) - epochMs;
}
/**
* Find the epoch instant whose wall-clock representation in `tz` matches
* `components`. Iteratively corrects for the timezone offset (two passes
* suffice for every IANA zone). When the requested wall-clock falls inside
* a DST spring-forward gap, returns the first existing instant strictly
* after the requested wall-clock — callers should re-verify the result
* matches their cron field set.
*/
export function fromWallClock(
components: Omit<WallClock, "weekday">,
tz: string
): number {
const targetUtc = utcEpoch(components);
// Fixed-point iteration: target's NY-wall-clock at instant `e`
// is exactly `target` when `e = targetUtc - offsetMs(e, tz)`.
// Start by guessing the instant's offset using `targetUtc` itself.
let candidate = targetUtc - offsetMs(targetUtc, tz);
for (let i = 0; i < 3; i += 1) {
const refined = targetUtc - offsetMs(candidate, tz);
if (refined === candidate) return candidate;
candidate = refined;
}
return candidate;
}
/**
* Detect a DST transition between two adjacent epoch instants in `tz`. The
* return value indicates the kind of transition; `null` means no transition
* occurred.
*/
export function detectDstTransition(
beforeEpochMs: number,
afterEpochMs: number,
tz: string
): "spring-forward" | "fall-back" | null {
const beforeOffset = offsetMs(beforeEpochMs, tz);
const afterOffset = offsetMs(afterEpochMs, tz);
if (beforeOffset === afterOffset) return null;
// In a spring-forward, local time jumps ahead — offset becomes more
// negative (e.g. EST -5h → EDT -4h means utcEpoch increases relative to
// real epoch, so offset increases). In fall-back, offset decreases.
return afterOffset > beforeOffset ? "spring-forward" : "fall-back";
}
/**
* Validate an IANA timezone name eagerly. Useful at the API boundary so
* configuration errors fail before any next-run computation.
*
* @throws CronError(CRON.TIMEZONE_INVALID)
*/
export function assertTimezone(tz: string): void {
getFormatter(tz);
}