@ultimat3/time
Advanced tools
+11
-0
@@ -35,2 +35,13 @@ # @ultimat3/time — agent notes | ||
| possible version of the rule above. Never reintroduce either half. | ||
| - **`Intl` answers "can I format this", never "is this an IANA zone", and the two stopped | ||
| agreeing.** ICU 78 (Bun 1.4) RESOLVES `CET`, `EST`, `EST5EDT`, `GMT`, `MST` and their families | ||
| where ICU 75 threw, so a runtime upgrade alone reopened the golden rule above — silently, and in | ||
| the direction that fails dangerous: an abbreviation names no DST rule. So the judgement is never | ||
| delegated to `Intl`. `canonicalTimeZone` asserts the structural property itself: a zone is | ||
| `Area/Location`, and `UTC` is the one legal exception. Never a denylist of the names ICU newly | ||
| accepts — that list grows with every tzdata and ICU release, and no rule in it keeps `CET` out | ||
| while letting `Japan` in, both being one label. The single-label `backward` links go with them | ||
| (`Japan` → `Asia/Tokyo`, `GB` → `Europe/London`) and that is the point: the slashed spelling is | ||
| the one that survives being a formatter-cache key. **Breaking at 6.0.0.** `zones.test.ts` pins | ||
| one named case per refused name, so an ICU bump that reopens one names it. | ||
| - **Never cache an `Intl` formatter on a raw caller string.** A zone and a locale both arrive from | ||
@@ -37,0 +48,0 @@ a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `canonicalLocale` |
+2
-2
| { | ||
| "name": "@ultimat3/time", | ||
| "version": "5.0.1", | ||
| "version": "6.0.0", | ||
| "description": "UTC instants, DST-correct zone math, cron, durations and Intl formatting with an explicit timezone", | ||
@@ -34,4 +34,4 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "5.0.1" | ||
| "@ultimat3/core": "6.0.0" | ||
| } | ||
| } |
+8
-1
@@ -24,2 +24,9 @@ # 🕒 @ultimat3/time | ||
| **A zone is `Area/Location`, or `UTC`.** Nothing else. `CET`, `EST5EDT` and `+02:00` name no | ||
| jurisdiction and carry no DST rule; the single-label `backward` links (`Japan`, `GB`, `Eire`) are | ||
| refused with them, because no rule keeps the first group out and lets the second in. Write the | ||
| slashed spelling — `Europe/Paris`, `Asia/Tokyo`, `Europe/London`. `Intl` is not the judge: ICU 78 | ||
| resolves what ICU 75 threw on, so the check is structural and does not move with the runtime. | ||
| **Breaking at 6.0.0**, `Japan` → `Asia/Tokyo`. | ||
| One **locale** is one key for the same reason — `Accept-Language` spells one locale `EN-us`, | ||
@@ -145,3 +152,3 @@ `en-US` and `en-latn-us`, and `formatDateTime` and `describeCron` collapse the three before they | ||
| |---|---| | ||
| | `X_TIMEZONE_INVALID` | not an IANA name (abbreviations and numeric offsets are rejected) | | ||
| | `X_TIMEZONE_INVALID` | not `Area/Location` or `UTC`: an abbreviation (`CET`), a numeric offset (`+02:00`), or a single-label legacy name (`Japan`) | | ||
| | `X_CRON_INVALID` | unparseable expression, or one that can never match | | ||
@@ -148,0 +155,0 @@ | `X_DURATION_INVALID` | `'3'` with no unit, trailing junk, unknown unit | |
+7
-2
@@ -66,7 +66,12 @@ /** | ||
| /** | ||
| * Two refused classes, and they need different instructions — one `fix:` that only described the | ||
| * abbreviations left an operator holding `"Japan"` reading advice about `CET`. A legacy single-label | ||
| * link has a mechanical replacement; an abbreviation has none, and saying so IS the instruction. | ||
| */ | ||
| export function timezoneInvalid(zone: string): TimeError { | ||
| return new TimeError({ | ||
| code: 'X_TIMEZONE_INVALID', | ||
| cause: `"${zone}" is not an IANA timezone name`, | ||
| fix: 'use an IANA identifier such as Europe/Berlin, America/New_York or UTC — never an abbreviation like CET or a numeric offset', | ||
| cause: `"${zone}" is not an IANA Area/Location zone name`, | ||
| fix: "use Area/Location, or UTC. A single-label legacy name swaps mechanically — Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC. An abbreviation or a numeric offset does not: CET and EST5EDT name no jurisdiction and carry no DST rule, so name the city whose clock you mean (Europe/Paris, America/New_York). Every accepted name: Intl.supportedValuesOf('timeZone')", | ||
| }); | ||
@@ -73,0 +78,0 @@ } |
@@ -28,5 +28,6 @@ /** | ||
| /** | ||
| * Deprecated aliases (`US/Eastern`, `Asia/Calcutta`) and the runtime's extras (`EST`, `GMT`) are | ||
| * not in the listed set, so they take the `Intl` probe once — bounded for the same reason every | ||
| * other cache here is. | ||
| * Deprecated aliases (`US/Eastern`, `Asia/Calcutta`) are not in the listed set — `supportedValuesOf` | ||
| * holds canonical zones only, and ICU does not fold a `backward` link into its target — so they | ||
| * take the `resolve` probe once, as do the runtime's extras (`EST`, `GMT`), the aliases to be | ||
| * accepted and the extras refused. Both cached: either can arrive from a header on every request. | ||
| */ | ||
@@ -49,5 +50,25 @@ const probed = new Map<string, string | ''>(); | ||
| /** | ||
| * `Intl` answers "can I format this", never "is this an IANA zone", and the two stopped agreeing: | ||
| * ICU 78 (Bun 1.4) resolves `CET`, `EST`, `EST5EDT`, `GMT` and `MST` where ICU 75 threw, so a | ||
| * runtime upgrade alone reopened the guard — silently, and in the direction that fails dangerous, | ||
| * because an abbreviation names no DST rule. The IANA-ness judgement is therefore never delegated | ||
| * to `Intl`: an identifier is `Area/Location`, and `UTC` is the one legal exception. | ||
| * | ||
| * That refuses the single-label `backward` links (`Japan`, `GB`, `Eire`) along with the | ||
| * abbreviations, and it is meant to. No structural rule keeps `CET` out and lets `Japan` in — both | ||
| * are one label — and the alternative is a denylist that grows with every tzdata and ICU release. | ||
| * `Asia/Tokyo` is the spelling that survives being a formatter-cache key, which is what this file | ||
| * is for. `Etc/GMT+2` passes: the `+` is inside a real zone name, and only a LEADING sign is a | ||
| * bare offset. | ||
| * | ||
| * `UTC` is compared on the RESOLVED name rather than assumed unreachable. It is unreachable today | ||
| * — `UTC` is in `supportedValuesOf` and never gets this far — but a runtime that folds an alias | ||
| * into its target would resolve `Etc/UTC` to `UTC`, and refusing `Etc/UTC` would be the bug. | ||
| */ | ||
| function resolve(zone: string): string | '' { | ||
| try { | ||
| return new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions().timeZone; | ||
| const resolved = new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions() | ||
| .timeZone; | ||
| return resolved === 'UTC' || resolved.includes('/') ? resolved : ''; | ||
| } catch { | ||
@@ -54,0 +75,0 @@ return ''; |
114053
3.41%2201
1.24%165
4.43%+ Added
- Removed
Updated