@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 +11 -0
- package/README.md +8 -1
- package/package.json +2 -2
- package/src/errors.ts +7 -2
- package/src/zone-canonical.ts +25 -4
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
|
|
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": "
|
|
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": "
|
|
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
|
|
70
|
-
fix:
|
|
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
|
|
package/src/zone-canonical.ts
CHANGED
|
@@ -25,9 +25,10 @@ function listedZones(): Map<string, string> {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Deprecated aliases (`US/Eastern`, `Asia/Calcutta`)
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
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
|
}
|