Initial Commit
This commit is contained in:
7
packages/foundation/timing/cron/src/timezone/index.ts
Normal file
7
packages/foundation/timing/cron/src/timezone/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type { WallClock } from "./timezone.js";
|
||||
export {
|
||||
assertTimezone,
|
||||
detectDstTransition,
|
||||
fromWallClock,
|
||||
toWallClock,
|
||||
} from "./timezone.js";
|
||||
88
packages/foundation/timing/cron/src/timezone/timezone.md
Normal file
88
packages/foundation/timing/cron/src/timezone/timezone.md
Normal 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.
|
||||
146
packages/foundation/timing/cron/src/timezone/timezone.test.ts
Normal file
146
packages/foundation/timing/cron/src/timezone/timezone.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
171
packages/foundation/timing/cron/src/timezone/timezone.ts
Normal file
171
packages/foundation/timing/cron/src/timezone/timezone.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user