@ultimat3/time 5.0.1 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -32,6 +32,17 @@
32
32
  so `currentTimeZone()` answered `UTC` for every request and every `@ultimat3/ui` server render
33
33
  formatted in UTC regardless of the zone the caller sent. Two ambient defaults is the worst
34
34
  possible version of the rule above. Never reintroduce either half.
35
+ - **`Intl` answers "can I format this", never "is this an IANA zone", and the two stopped
36
+ agreeing.** ICU 78 (Bun 1.4) RESOLVES `CET`, `EST`, `EST5EDT`, `GMT`, `MST` and their families
37
+ where ICU 75 threw, so a runtime upgrade alone reopened the golden rule above — silently, and in
38
+ the direction that fails dangerous: an abbreviation names no DST rule. So the judgement is never
39
+ delegated to `Intl`. `canonicalTimeZone` asserts the structural property itself: a zone is
40
+ `Area/Location`, and `UTC` is the one legal exception. Never a denylist of the names ICU newly
41
+ accepts — that list grows with every tzdata and ICU release, and no rule in it keeps `CET` out
42
+ while letting `Japan` in, both being one label. The single-label `backward` links go with them
43
+ (`Japan` → `Asia/Tokyo`, `GB` → `Europe/London`) and that is the point: the slashed spelling is
44
+ the one that survives being a formatter-cache key. **Breaking at 6.0.0.** `zones.test.ts` pins
45
+ one named case per refused name, so an ICU bump that reopens one names it.
35
46
  - **Never cache an `Intl` formatter on a raw caller string.** A zone and a locale both arrive from
36
47
  a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `canonicalLocale`
37
48
  for a locale) and the cache must be bounded (`cachedFormatter`). **`cachedFormatter`,
package/README.md CHANGED
@@ -21,6 +21,13 @@ answers the canonical spelling (or `undefined`), and `assertTimeZone` / `resolve
21
21
  return it. Anything reading a zone off a request header should canonicalize before caching on it:
22
22
  4,096 casings of `Europe/Berlin` used to mint 4,096 permanent `Intl.DateTimeFormat`s, 31 MB.
23
23
 
24
+ **A zone is `Area/Location`, or `UTC`.** Nothing else. `CET`, `EST5EDT` and `+02:00` name no
25
+ jurisdiction and carry no DST rule; the single-label `backward` links (`Japan`, `GB`, `Eire`) are
26
+ refused with them, because no rule keeps the first group out and lets the second in. Write the
27
+ slashed spelling — `Europe/Paris`, `Asia/Tokyo`, `Europe/London`. `Intl` is not the judge: ICU 78
28
+ resolves what ICU 75 threw on, so the check is structural and does not move with the runtime.
29
+ **Breaking at 6.0.0**, `Japan` → `Asia/Tokyo`.
30
+
24
31
  One **locale** is one key for the same reason — `Accept-Language` spells one locale `EN-us`,
25
32
  `en-US` and `en-latn-us`, and `formatDateTime` and `describeCron` collapse the three before they
26
33
  reach a formatter cache. The cap stays either way: an unknown `-u-` extension value survives
@@ -141,7 +148,7 @@ negated; an empty one is `0` in either direction, never `-0`.
141
148
 
142
149
  | Code | When |
143
150
  |---|---|
144
- | `X_TIMEZONE_INVALID` | not an IANA name (abbreviations and numeric offsets are rejected) |
151
+ | `X_TIMEZONE_INVALID` | not `Area/Location` or `UTC`: an abbreviation (`CET`), a numeric offset (`+02:00`), or a single-label legacy name (`Japan`) |
145
152
  | `X_CRON_INVALID` | unparseable expression, or one that can never match |
146
153
  | `X_DURATION_INVALID` | `'3'` with no unit, trailing junk, unknown unit |
147
154
  | `X_DST_AMBIGUOUS` | overlap hit with `overlap: 'throw'` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/time",
3
- "version": "5.0.1",
3
+ "version": "6.0.0",
4
4
  "description": "UTC instants, DST-correct zone math, cron, durations and Intl formatting with an explicit timezone",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,6 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "5.0.1"
34
+ "@ultimat3/core": "6.0.0"
35
35
  }
36
36
  }
package/src/errors.ts CHANGED
@@ -63,11 +63,16 @@ export function scheduleInvalid(field: string, value: unknown, range: string): T
63
63
  });
64
64
  }
65
65
 
66
+ /**
67
+ * Two refused classes, and they need different instructions — one `fix:` that only described the
68
+ * abbreviations left an operator holding `"Japan"` reading advice about `CET`. A legacy single-label
69
+ * link has a mechanical replacement; an abbreviation has none, and saying so IS the instruction.
70
+ */
66
71
  export function timezoneInvalid(zone: string): TimeError {
67
72
  return new TimeError({
68
73
  code: 'X_TIMEZONE_INVALID',
69
- cause: `"${zone}" is not an IANA timezone name`,
70
- fix: 'use an IANA identifier such as Europe/Berlin, America/New_York or UTC never an abbreviation like CET or a numeric offset',
74
+ cause: `"${zone}" is not an IANA Area/Location zone name`,
75
+ 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')",
71
76
  });
72
77
  }
73
78
 
@@ -25,9 +25,10 @@ function listedZones(): Map<string, string> {
25
25
  }
26
26
 
27
27
  /**
28
- * Deprecated aliases (`US/Eastern`, `Asia/Calcutta`) and the runtime's extras (`EST`, `GMT`) are
29
- * not in the listed set, so they take the `Intl` probe once bounded for the same reason every
30
- * other cache here is.
28
+ * Deprecated aliases (`US/Eastern`, `Asia/Calcutta`) are not in the listed set `supportedValuesOf`
29
+ * holds canonical zones only, and ICU does not fold a `backward` link into its target so they
30
+ * take the `resolve` probe once, as do the runtime's extras (`EST`, `GMT`), the aliases to be
31
+ * accepted and the extras refused. Both cached: either can arrive from a header on every request.
31
32
  */
32
33
  const probed = new Map<string, string | ''>();
33
34
 
@@ -45,9 +46,29 @@ export function canonicalTimeZone(zone: string): string | undefined {
45
46
  return resolved === '' ? undefined : resolved;
46
47
  }
47
48
 
49
+ /**
50
+ * `Intl` answers "can I format this", never "is this an IANA zone", and the two stopped agreeing:
51
+ * ICU 78 (Bun 1.4) resolves `CET`, `EST`, `EST5EDT`, `GMT` and `MST` where ICU 75 threw, so a
52
+ * runtime upgrade alone reopened the guard — silently, and in the direction that fails dangerous,
53
+ * because an abbreviation names no DST rule. The IANA-ness judgement is therefore never delegated
54
+ * to `Intl`: an identifier is `Area/Location`, and `UTC` is the one legal exception.
55
+ *
56
+ * That refuses the single-label `backward` links (`Japan`, `GB`, `Eire`) along with the
57
+ * abbreviations, and it is meant to. No structural rule keeps `CET` out and lets `Japan` in — both
58
+ * are one label — and the alternative is a denylist that grows with every tzdata and ICU release.
59
+ * `Asia/Tokyo` is the spelling that survives being a formatter-cache key, which is what this file
60
+ * is for. `Etc/GMT+2` passes: the `+` is inside a real zone name, and only a LEADING sign is a
61
+ * bare offset.
62
+ *
63
+ * `UTC` is compared on the RESOLVED name rather than assumed unreachable. It is unreachable today
64
+ * — `UTC` is in `supportedValuesOf` and never gets this far — but a runtime that folds an alias
65
+ * into its target would resolve `Etc/UTC` to `UTC`, and refusing `Etc/UTC` would be the bug.
66
+ */
48
67
  function resolve(zone: string): string | '' {
49
68
  try {
50
- return new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions().timeZone;
69
+ const resolved = new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions()
70
+ .timeZone;
71
+ return resolved === 'UTC' || resolved.includes('/') ? resolved : '';
51
72
  } catch {
52
73
  return '';
53
74
  }