@ultimat3/time 9.0.0 → 10.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
@@ -12,6 +12,7 @@
12
12
  | `zone-canonical.ts` | one zone, one key: `canonicalTimeZone` — the casing/alias collapse every cache keys on |
13
13
  | `zoned.ts` | `toZoned` / `fromZoned` + gap and overlap policies. Everything depends on this. |
14
14
  | `format.ts` | `Intl` rendering. Every function takes `locale` **and** `zone`. |
15
+ | `locale.ts` | `assertLocale` — the ONE screen a caller-supplied BCP 47 tag passes before `Intl` |
15
16
  | `duration.ts` | `'2h30m'` ⇄ ms |
16
17
  | `cron.ts` | barrel over the three cron modules — the only one `index.ts` re-exports |
17
18
  | `cron-parse.ts` | field grammar → `CronExpression`. Non-integer, non-name tokens are rejected. |
@@ -43,8 +44,20 @@
43
44
  (`Japan` → `Asia/Tokyo`, `GB` → `Europe/London`) and that is the point: the slashed spelling is
44
45
  the one that survives being a formatter-cache key. **Breaking at 6.0.0.** `zones.test.ts` pins
45
46
  one named case per refused name, so an ICU bump that reopens one names it.
47
+ - **A malformed locale tag is `X_LOCALE_INVALID` at EVERY entry point, `As of 2026-08-23`** —
48
+ `formatDateTime`, `formatDate`, `formatTime`, `formatWithOffset`, `formatRange`, `formatRelative`,
49
+ `formatDuration` and `zoneAbbrev`, not `describeCron` alone. Each of the other eight passed the
50
+ caller's raw string to an `Intl` constructor, so `formatRelative(at, { locale: 'en_US', … })` died
51
+ as a bare, uncoded `RangeError` several frames from the `Accept-Language` header it came out of —
52
+ a code that has shipped since 1.0, with a runnable `fix:`, thrown by exactly one of nine callers.
53
+ `format.ts` argued the pass-through decided "a cache key, never whether a locale is acceptable",
54
+ which is true of the cache and was not an argument for letting the tag through. **Breaking at
55
+ 9.x.** `assertLocale` (`locale.ts`) is the one screen and it VALIDATES AND CANONICALIZES in one
56
+ step — `Intl.getCanonicalLocales` runs the same structural check `supportedLocalesOf` throws on
57
+ and hands back the spelling the cache keys on, so there is no second question to ask. Well-formed
58
+ but unknown to ICU (`zz`) is **not** refused: `Intl` falls back, and so must a rendered page.
46
59
  - **Never cache an `Intl` formatter on a raw caller string.** A zone and a locale both arrive from
47
- a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `canonicalLocale`
60
+ a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `assertLocale`
48
61
  for a locale) and the cache must be bounded (`cachedFormatter`). **`cachedFormatter`,
49
62
  `MAX_CACHED_FORMATTERS` and `canonicalLocale` are `@ultimat3/core`'s as of 2.0.0**, not this
50
63
  package's: `@ultimat3/money` hit the identical unbounded-`Map`-on-a-header bug and tier 1 may not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/time",
3
- "version": "9.0.0",
3
+ "version": "10.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",
@@ -34,6 +34,6 @@
34
34
  "test": "bun test"
35
35
  },
36
36
  "dependencies": {
37
- "@ultimat3/core": "9.0.0"
37
+ "@ultimat3/core": "10.0.0"
38
38
  }
39
39
  }
@@ -4,9 +4,10 @@
4
4
  * ship English to every locale that forgot the argument — so injection is mandatory, not opt-in.
5
5
  */
6
6
 
7
- import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
7
+ import { cachedFormatter } from '@ultimat3/core';
8
8
  import { type CronExpression, parseCronOnce } from './cron-parse';
9
- import { cronNotDescribable, localeInvalid } from './errors';
9
+ import { cronNotDescribable } from './errors';
10
+ import { assertLocale } from './locale';
10
11
 
11
12
  export interface CronPhrases {
12
13
  everyMinute: string;
@@ -99,16 +100,6 @@ function clockTimes(
99
100
  return `${shown} ${fill(phrases.andMore, { n: times.length - MAX_LISTED_TIMES })}`;
100
101
  }
101
102
 
102
- /**
103
- * `Intl` throws a bare `RangeError` on a malformed tag; convert it once, at the entry point — and
104
- * hand back the canonical spelling, so validating and keying are the same single step.
105
- */
106
- function assertLocale(locale: string): string {
107
- const tag = canonicalLocale(locale);
108
- if (tag === undefined) throw localeInvalid(locale);
109
- return tag;
110
- }
111
-
112
103
  /** Step fields: an evenly spaced set starting at 0 that covers the whole range. */
113
104
  function uniformStep(values: readonly number[], size: number): number | undefined {
114
105
  const first = values[0];
@@ -130,7 +121,7 @@ function fill(template: string, vars: Readonly<Record<string, string | number>>)
130
121
 
131
122
  /**
132
123
  * Canonically keyed **and** hard-capped, because `locale` can arrive from an Accept-Language
133
- * header. `canonicalLocale` collapses the spellings of one locale — `EN-us`, `en-latn-us` — but it
124
+ * header. `assertLocale` collapses the spellings of one locale — `EN-us`, `en-latn-us` — but it
134
125
  * still returns a distinct string for every unknown `-u-` extension value, so the key alone does
135
126
  * not bound anything and only the cap keeps the key space finite. Neither half is redundant. The
136
127
  * cap and its FIFO live in `@ultimat3/core`'s `intl-cache.ts`, because `zones.ts`, `format.ts` and
package/src/duration.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { durationInvalid, scheduleInvalid } from './errors';
7
+ import { assertLocale } from './locale';
7
8
 
8
9
  export const MS = 1;
9
10
  export const SECOND = 1000;
@@ -112,6 +113,9 @@ export function formatDuration(
112
113
  if (!Number.isInteger(maxUnits) || maxUnits < 1) {
113
114
  throw scheduleInvalid('maxUnits', maxUnits, 'at least 1');
114
115
  }
116
+ // Screened once, here, rather than at each of the three `Intl` constructions below — and before
117
+ // any of them, so a malformed tag is one refusal and never a partially built string.
118
+ const tag = assertLocale(locale);
115
119
  let remaining = Math.abs(Math.round(ms));
116
120
  const pieces: string[] = [];
117
121
 
@@ -121,19 +125,19 @@ export function formatDuration(
121
125
  if (count === 0) continue;
122
126
  remaining -= count * scale;
123
127
  pieces.push(
124
- new Intl.NumberFormat(locale, { style: 'unit', unit, unitDisplay: style }).format(count),
128
+ new Intl.NumberFormat(tag, { style: 'unit', unit, unitDisplay: style }).format(count),
125
129
  );
126
130
  }
127
131
 
128
132
  if (pieces.length === 0) {
129
- return new Intl.NumberFormat(locale, {
133
+ return new Intl.NumberFormat(tag, {
130
134
  style: 'unit',
131
135
  unit: 'second',
132
136
  unitDisplay: style,
133
137
  }).format(0);
134
138
  }
135
139
 
136
- const joined = new Intl.ListFormat(locale, { style: 'narrow', type: 'unit' }).format(pieces);
140
+ const joined = new Intl.ListFormat(tag, { style: 'narrow', type: 'unit' }).format(pieces);
137
141
  return ms < 0 ? `-${joined}` : joined;
138
142
  }
139
143
 
package/src/errors.ts CHANGED
@@ -44,7 +44,6 @@ export class TimeError extends UltimateError {
44
44
  code: init.code,
45
45
  cause: init.cause,
46
46
  fix: init.fix,
47
- docs: `https://ultimate.dev/errors/${init.code}`,
48
47
  });
49
48
  }
50
49
  }
package/src/format.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  * because "the server's timezone" is never the answer to "what time is it for the user".
5
5
  */
6
6
 
7
- import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
7
+ import { cachedFormatter } from '@ultimat3/core';
8
8
  import { differenceMs, type Instant } from './instant';
9
+ import { assertLocale } from './locale';
9
10
  import { isoDateInZone } from './zoned';
10
11
  import { assertTimeZone, type TimeZone } from './zones';
11
12
 
@@ -120,7 +121,7 @@ const RELATIVE_UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [
120
121
  /** `in 3 days` / `2 hours ago`, picking the largest unit that fits. */
121
122
  export function formatRelative(at: Instant, options: FormatRelativeOptions): string {
122
123
  const delta = differenceMs(options.now, at);
123
- const formatter = new Intl.RelativeTimeFormat(options.locale, {
124
+ const formatter = new Intl.RelativeTimeFormat(assertLocale(options.locale), {
124
125
  numeric: options.numeric ?? 'auto',
125
126
  style: options.style ?? 'long',
126
127
  });
@@ -176,16 +177,18 @@ export function ordinal(value: number): string {
176
177
  const cache = new Map<string, Intl.DateTimeFormat>();
177
178
 
178
179
  /**
179
- * Bounded, and keyed on a zone `assertTimeZone` and a locale `canonicalLocale` have both already
180
+ * Bounded, and keyed on a zone `assertTimeZone` and a locale `assertLocale` have both already
180
181
  * canonicalized — `Accept-Language` sends `EN-us` and `en-US` for one locale, and each spelling
181
182
  * used to mint its own permanent entry. The bound stays: an unknown `-u-` extension value survives
182
183
  * canonicalization as a distinct string, so only the cap keeps this key space finite.
183
184
  *
184
- * A tag `Intl` cannot parse falls through unchanged, so the `Intl.DateTimeFormat` constructor
185
- * still raises it this seam decides a cache key, never whether a locale is acceptable.
185
+ * A tag `Intl` cannot parse is refused here as `X_LOCALE_INVALID`, the same code `describeCron`
186
+ * has always raised. It used to fall through unchanged so the `Intl.DateTimeFormat` constructor
187
+ * raised a bare `RangeError` instead — one package answering "is this locale acceptable" two ways,
188
+ * and the answer a caller could act on was the one seven of the eight entry points did not give.
186
189
  */
187
190
  function formatterFor(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
188
- const tag = canonicalLocale(locale) ?? locale;
191
+ const tag = assertLocale(locale);
189
192
  const key = `${tag}|${JSON.stringify(options)}`;
190
193
  return cachedFormatter(cache, key, () => new Intl.DateTimeFormat(tag, options));
191
194
  }
package/src/locale.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Single responsibility: the one place a caller-supplied BCP 47 tag is screened before it reaches
2
+ // an `Intl` constructor. One question, one answer (axiom 1) — `cron-describe.ts` refused a
3
+ // malformed tag from the start while seven sibling formatters handed the raw string to `Intl` and
4
+ // let a bare, uncoded `RangeError` escape several frames from the header it came out of.
5
+
6
+ import { canonicalLocale } from '@ultimat3/core';
7
+ import { localeInvalid } from './errors';
8
+
9
+ /**
10
+ * The canonical spelling of a well-formed tag, or `X_LOCALE_INVALID`.
11
+ *
12
+ * Validating and keying are one step: `Intl.getCanonicalLocales` runs exactly the structural check
13
+ * `Intl.DateTimeFormat.supportedLocalesOf` throws on — which is what `localeInvalid`'s `fix:` tells
14
+ * the caller to run — and unlike it hands back the spelling every formatter cache keys on, so
15
+ * `EN-us` and `en-US` cannot mint two entries for one locale.
16
+ *
17
+ * Well-formed but unknown to this runtime's ICU (`zz`) is NOT refused: `Intl` falls back for those,
18
+ * and a user carrying a locale the runtime has no data for must still get a rendered page.
19
+ */
20
+ export function assertLocale(locale: string): string {
21
+ const tag = canonicalLocale(locale);
22
+ if (tag === undefined) throw localeInvalid(locale);
23
+ return tag;
24
+ }
package/src/zones.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  * is no offset table to keep in sync and no `date-fns-tz` dependency.
5
5
  */
6
6
 
7
- import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
7
+ import { cachedFormatter } from '@ultimat3/core';
8
8
  import { timezoneInvalid } from './errors';
9
9
  import type { Instant } from './instant';
10
+ import { assertLocale } from './locale';
10
11
  import { canonicalTimeZone } from './zone-canonical';
11
12
 
12
13
  /** An IANA identifier: `Europe/Berlin`, `Asia/Kathmandu`, `UTC`. Never `CET`, never `+01:00`. */
@@ -118,9 +119,10 @@ export function zoneAbbrev(
118
119
  style: 'short' | 'long' | 'shortOffset' | 'longOffset' = 'short',
119
120
  ): string {
120
121
  const canonical = assertTimeZone(zone);
121
- // A tag `Intl` cannot parse falls through unchanged, exactly as in `format.ts`: this decides a
122
- // cache key, never whether a locale is acceptable.
123
- const tag = canonicalLocale(locale) ?? locale;
122
+ // Both arguments arrive from a request header on the path this function exists for, so a tag
123
+ // `Intl` cannot parse is refused with a code exactly as an unknown zone is — `X_LOCALE_INVALID`,
124
+ // the same one `describeCron` and every formatter in `format.ts` raise.
125
+ const tag = assertLocale(locale);
124
126
  // The one `Intl` construction in this package that escaped the shared cache: it built a formatter
125
127
  // per call on the caller's raw zone and locale, so an `x-timezone` an app renders a label from
126
128
  // paid for a fresh `Intl.DateTimeFormat` every time and an unknown one escaped as a `RangeError`.