@ultimat3/time 2.0.0 → 4.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
@@ -10,8 +10,6 @@
10
10
  | `instant.ts` | the UTC `Instant` brand, ISO/epoch conversion, `now(clock)`, `epoch()` |
11
11
  | `zones.ts` | IANA validation, `offsetAt` (minutes east), zone labels |
12
12
  | `zone-canonical.ts` | one zone, one key: `canonicalTimeZone` — the casing/alias collapse every cache keys on |
13
- | `locale-canonical.ts` | one locale, one key: `canonicalLocale` — the same collapse for the `Accept-Language` half |
14
- | `intl-cache.ts` | the one bounded FIFO every `Intl` formatter cache in this package uses |
15
13
  | `zoned.ts` | `toZoned` / `fromZoned` + gap and overlap policies. Everything depends on this. |
16
14
  | `format.ts` | `Intl` rendering. Every function takes `locale` **and** `zone`. |
17
15
  | `duration.ts` | `'2h30m'` ⇄ ms |
@@ -22,6 +20,7 @@
22
20
  | `schedule.ts` | `nextLocalSlot` — "09:00 local tomorrow" |
23
21
  | `business.ts` | weekends as config, holidays as local dates |
24
22
  | `context.ts` | request timezone: which source wins, and reading core's `Ctx.tz` back off the ALS |
23
+ | `plain-date.ts` | `PlainDate` — a calendar date with no time and no zone, and the two conversions to an instant |
25
24
 
26
25
  ## Rules
27
26
 
@@ -35,7 +34,10 @@
35
34
  possible version of the rule above. Never reintroduce either half.
36
35
  - **Never cache an `Intl` formatter on a raw caller string.** A zone and a locale both arrive from
37
36
  a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `canonicalLocale`
38
- for a locale) and the cache must be bounded (`cachedFormatter`, `intl-cache.ts`). An unbounded
37
+ for a locale) and the cache must be bounded (`cachedFormatter`). **`cachedFormatter`,
38
+ `MAX_CACHED_FORMATTERS` and `canonicalLocale` are `@ultimat3/core`'s as of 2.0.0**, not this
39
+ package's: `@ultimat3/money` hit the identical unbounded-`Map`-on-a-header bug and tier 1 may not
40
+ import sideways, so the mechanism moved down a tier rather than being copied. An unbounded
39
41
  `Map` keyed on `x-timezone` grew 31 MB for 4,096 casings of one zone name, and the casing space
40
42
  of a 13-letter zone is 2^12. **Both halves, always** — a canonical key does not bound anything
41
43
  (an unknown `-u-` extension value survives canonicalization as a distinct string) and the cap
@@ -50,6 +52,67 @@
50
52
  no seconds phrase, so a 6-field expression with a non-trivial seconds field is
51
53
  `X_CRON_NOT_DESCRIBABLE`. Adding a required field to `CronPhrases` would break every caller
52
54
  (`packages/cli/src/cmd-tasks.ts` builds one) to describe a schedule almost nobody writes.
55
+ - **A `PlainDate` is a branded STRING, and the golden rule does not apply to it** — decided
56
+ 2026-08 for `@ultimat3/entity`'s `date()` column. "Never format a date without a zone" is about
57
+ INSTANTS; a calendar date names no instant, so it needs no zone, and giving it one is the bug:
58
+ `effective_on` stored as a `timestamptz` is a different date on either side of midnight for half
59
+ the planet. Not a `Date` (that is an instant, and binding one to a Postgres `date` parameter
60
+ fails outright — `time zone "gmt-0500" not recognized`, measured on 17.10) and not
61
+ `{ year, month, day }` (an object sorts by nothing and JSON-stringifies as three fields). The ISO
62
+ string sorts lexicographically exactly as it sorts chronologically, which is why every cursor,
63
+ `orderBy` and `compare` in the framework handles it with no special case at all.
64
+ - **`plainDateIn` takes a zone and `plainDateUtc` does not, and neither is a default for the
65
+ other.** An instant has a calendar date only in a zone; a `Date` a driver returns for a `date`
66
+ column is midnight UTC and reading its LOCAL fields loses a day west of Greenwich. `bun test`
67
+ pins the process to UTC, so that bug is invisible to every in-process test — `plain-date.test.ts`
68
+ spawns a subprocess with `TZ=America/Los_Angeles` for exactly one assertion, and that is the only
69
+ reason it can fail.
70
+ - **`fromIso` refuses a clock time with no offset.** `new Date('2026-03-14T09:00')` is the
71
+ PROCESS's 09:00, so one CSV row imported on two pods becomes two instants — the ambient default
72
+ this package exists to abolish, inside its own entry point. `Z` or an offset, or `X_INSTANT_INVALID`;
73
+ wall-clock input is `fromZoned(wall, zone)`, which names its zone. A date-only form carries no
74
+ clock time and is UTC by specification, so it still parses.
75
+ - **`fromEpochMs` checks the `Date` RANGE, not `Number.isFinite`.** ±8.64e15 ms is the limit, so a
76
+ finite `1e16` used to hand back an Invalid Date branded as an `Instant` — a value `isInstant`
77
+ answers `false` for (the type's own predicate rejecting what its constructor certified) and
78
+ `toIso` throws a bare `RangeError` out of. `fromEpochSeconds`, `addMs`, `subtractMs` and
79
+ `now(clock)` all reach it and none re-checks, so the one test is `new Date(ms).getTime()` being
80
+ NaN — exactly what `instant()` already does.
81
+ - **`configureTime({ defaultZone })` goes through `assertTimeZone`**, which validates AND
82
+ canonicalizes. It did neither: `'Mars/Olympus'` was accepted at boot and first refused inside a
83
+ formatter at render time, from a stack naming no configuration, and `'eUrOpE/bErLiN'` travelled
84
+ the process as its own zone string minting a permanent entry in every formatter cache. It throws
85
+ where `resolveTimeZone` skips — a stale header must not fail a request, a default nothing can
86
+ fall back to is a boot-time mistake with no second answer.
87
+ - **`formatIsoDate` is built from `isoDateInZone`, never from `Intl` directly.** `year: 'numeric'`
88
+ neither zero-pads a year below 1000 nor carries the era, so it answered `'50-01-01'` where
89
+ `isoDateInZone` answered `'0050-01-01'` — two functions in one package answering one question
90
+ differently, and the short form matches no ISO pattern and is rejected by the `<input type="date">`
91
+ it exists for. One padding rule, in one place.
92
+ - **`ordinal(value)` takes no locale.** It used to accept one, select the plural CATEGORY with it,
93
+ and append the ENGLISH suffix for that category regardless: `ordinal(1, 'de')` was `'1th'`. A
94
+ parameter that cannot change the answer correctly is removed, so a caller wanting a localized
95
+ ordinal hears it from `tsc`. **Breaking.**
96
+ - **A day count is a whole number, and `formatDuration`'s `maxUnits` is at least 1.**
97
+ `addBusinessDays(at, 0.5)` moved a whole day and `NaN` returned the input unchanged, which reads
98
+ as "no movement was needed"; `maxUnits: 0` made the ceiling test true before the first unit, so
99
+ every duration rendered as "0 sec". Both refuse through `scheduleInvalid`, the in-package generic
100
+ range refusal `addPlainDays` already uses — not clamped, for the reason that error's own fix line
101
+ gives.
102
+ - **`toSeconds` carries the sign out and rounds the MAGNITUDE.** `Math.round` breaks ties toward
103
+ `+Infinity`, so `'1500ms'` was 2 and `'-1500ms'` was -1. `@ultimat3/money`'s `rounding.ts` is the
104
+ framework's statement of this, and its `signed()` is why zero never comes back as `-0`.
105
+ - **`parseDuration` rejects an ISO body by its GROUPS, never by its total.** `'PT0S'` — the
106
+ canonical zero most emitters write — was refused along with `'PT0H0M0S'` and `'P0W'`, while
107
+ `'P0D'` was let through by a special case. `ISO_8601` already requires a component group for any
108
+ body past a bare `'P'`, which is the case the guard was written for.
109
+ - **An impossible day/month pair is refused by `parseCron`, in constant time.**
110
+ `isValidCron('0 0 30 2 *')` answered `true` and the refusal arrived ~150 ms later out of
111
+ `nextCronOccurrence`, after 200,000 walk steps — a cost `firedSince` pays on every tick of the
112
+ scheduler's leader loop. February is 29 in the table because leap years happen, and the check
113
+ applies ONLY when day-of-month is restricted and day-of-week is not: Vixie's OR means
114
+ `0 0 30 2 5` fires every Friday in February, so refusing it would break a working schedule.
115
+ `MAX_STEPS` stays as the backstop for what the check cannot see.
53
116
  - Never add `86_400_000` to cross a day boundary — use `addDaysInZone` / `fromZoned`.
54
117
  - Never take the clock from `Date.now()`; accept a `Clock` (`now(clock)`).
55
118
  - Cron and schedules iterate the **local wall clock**, then convert once with `fromZoned`.
package/README.md CHANGED
@@ -23,8 +23,15 @@ return it. Anything reading a zone off a request header should canonicalize befo
23
23
 
24
24
  One **locale** is one key for the same reason — `Accept-Language` spells one locale `EN-us`,
25
25
  `en-US` and `en-latn-us`, and `formatDateTime` and `describeCron` collapse the three before they
26
- reach a formatter cache. The cap in `intl-cache.ts` stays either way: an unknown `-u-` extension
27
- value survives canonicalization as a distinct string, so the key bounds nothing on its own.
26
+ reach a formatter cache. The cap stays either way: an unknown `-u-` extension value survives
27
+ canonicalization as a distinct string, so the key bounds nothing on its own. Both halves —
28
+ `canonicalLocale` and `cachedFormatter` — are `@ultimat3/core`'s as of 2.0.0, so `@ultimat3/money`
29
+ reads the same bound rather than a copy of it.
30
+
31
+ `fromIso` refuses a bare local timestamp. `2026-03-14T09:00` names a different instant on every
32
+ pod, because `new Date` resolves it through the process's zone — so a clock time reaches an
33
+ `Instant` only with `Z` or an offset beside it (`X_INSTANT_INVALID`), and wall-clock input goes
34
+ through `fromZoned(wall, zone)`, which names the zone it is stated in.
28
35
 
29
36
  Every value this package hands back is its own object: `instant(date)` copies rather than branding
30
37
  the caller's `Date`, and `epoch()` is a function — the `EPOCH` constant it replaces was one shared
@@ -78,6 +85,34 @@ one of these takes the zone explicitly, and none of them has a default.
78
85
  `daysBetween` counts boundaries, not milliseconds: 23 real hours across spring forward is `1`,
79
86
  and 24 real hours inside a 25-hour fall-back day is `0`.
80
87
 
88
+ ## A calendar date is not an instant
89
+
90
+ `PlainDate` is `2026-03-14`: a year, a month and a day, with **no time and therefore no zone**,
91
+ `As of 2026-08`. The golden rule at the top of this page is about instants — a `PlainDate` needs no
92
+ zone because it names no moment, and that is the honest modelling of the values that have one.
93
+ `effective_on` is the date a rate applies; a birthday is a date; an invoice period is two of them.
94
+ Stored as a `timestamptz`, every one of those is a different date on either side of midnight for
95
+ half the planet.
96
+
97
+ | Function | Answers |
98
+ |---|---|
99
+ | `plainDate(value)` / `isPlainDate(value)` | the date, or a refusal — `2026-02-30` is not one, and a regex cannot say so |
100
+ | `plainDateOf({ year, month, day })` | the same, from fields; an impossible day throws instead of rolling into the next month |
101
+ | `plainDateParts(date)` | back to fields |
102
+ | `plainDateIn(at, zone)` | the calendar date an **instant** falls on, in a named zone. It takes the zone because there is no other honest way to make this conversion |
103
+ | `plainDateUtc(at)` | the date a `Date` holds read as UTC — for the one caller that needs it: a Postgres driver returns a `date` column as midnight UTC |
104
+ | `plainDateToUtcInstant(date)` | midnight UTC of the date. The inverse of `plainDateUtc`, and never of `plainDateIn` |
105
+ | `addPlainDays(date, days)` / `plainDaysBetween(from, to)` | calendar arithmetic with no zone in it: a DST day is one day, because there is no zone to shorten |
106
+ | `comparePlainDates(a, b)` | `-1` / `0` / `1` |
107
+
108
+ It is a branded **string**, and both halves are load-bearing. Not a `Date`: a `Date` is an instant,
109
+ so `2026-03-14T00:00:00Z` is the 13th anywhere west of Greenwich, and binding one to a Postgres
110
+ `date` parameter fails outright (`time zone "gmt-0500" not recognized`, measured on 17.10). A
111
+ string: the ISO form sorts lexicographically exactly as it sorts chronologically, round-trips
112
+ through `JSON.stringify` as itself, and is the literal Postgres accepts and returns.
113
+
114
+ `@ultimat3/entity`'s `date()` column is the one that stores it.
115
+
81
116
  ## Cron
82
117
 
83
118
  `parseCron` handles 5 or 6 fields, `*/n`, ranges, lists, named months and days, `@daily`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/time",
3
- "version": "2.0.0",
3
+ "version": "4.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": "2.0.0"
34
+ "@ultimat3/core": "4.0.0"
35
35
  }
36
36
  }
package/src/business.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * much of the Gulf, Sunday-only in parts of Asia, Saturday/Sunday in the West.
4
4
  */
5
5
 
6
+ import { scheduleInvalid } from './errors';
6
7
  import type { Instant } from './instant';
7
8
  import { addDaysInZone, daysBetween, isoDateInZone, toZoned } from './zoned';
8
9
  import type { TimeZone } from './zones';
@@ -44,8 +45,16 @@ export function isBusinessDay(at: Instant, calendar: BusinessCalendar): boolean
44
45
  * Move `days` business days forward (or back, if negative), keeping the local wall-clock
45
46
  * time. `days === 0` returns the input untouched, even on a weekend — callers that want
46
47
  * "the next business day" should ask for 1.
48
+ *
49
+ * The count is a WHOLE number of days, checked the way `plain-date.ts`'s `addPlainDays` checks its
50
+ * own: `0.5` used to reach `Math.abs(days)` as a loop bound and move a whole day, and a `NaN` —
51
+ * the shape a corrupted config or a failed parse takes — failed `remaining > 0` on the first test
52
+ * and returned the input, which reads as "no movement was needed" rather than as a failure.
47
53
  */
48
54
  export function addBusinessDays(at: Instant, days: number, calendar: BusinessCalendar): Instant {
55
+ if (!Number.isSafeInteger(days)) {
56
+ throw scheduleInvalid('days', days, 'a whole number of business days');
57
+ }
49
58
  if (days === 0) return at;
50
59
  const step = days > 0 ? 1 : -1;
51
60
  let remaining = Math.abs(days);
package/src/context.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { tryUseContext } from '@ultimat3/core';
8
8
  import { canonicalTimeZone } from './zone-canonical';
9
- import { type TimeZone, UTC } from './zones';
9
+ import { assertTimeZone, type TimeZone, UTC } from './zones';
10
10
 
11
11
  /** Header a client sets from `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
12
12
  export const TIMEZONE_HEADER = 'x-timezone';
@@ -45,8 +45,25 @@ const DEFAULT_ORDER: readonly TimeZoneSourceName[] = ['user', 'cookie', 'query',
45
45
 
46
46
  let config: TimeConfig = { defaultZone: UTC, order: DEFAULT_ORDER };
47
47
 
48
+ /**
49
+ * The default zone goes through `assertTimeZone`, which both VALIDATES and CANONICALIZES — the two
50
+ * halves `resolveTimeZone` already promises for every other source, on the one source that skipped
51
+ * them. Unchecked, `configureTime({ defaultZone: 'Mars/Olympus' })` was accepted at boot and first
52
+ * refused inside a formatter at render time, from a stack that names no configuration; and
53
+ * `'eUrOpE/bErLiN'` travelled the process as its own zone string, minting a permanent entry in
54
+ * every formatter cache it reached.
55
+ *
56
+ * It throws, where `resolveTimeZone` skips: a stale header from an old client must not fail a
57
+ * request, but a default nothing can fall back to is a boot-time mistake with no second answer.
58
+ */
48
59
  export function configureTime(partial: Partial<TimeConfig>): TimeConfig {
49
- config = { ...config, ...partial };
60
+ const defaultZone =
61
+ partial.defaultZone === undefined ? undefined : assertTimeZone(partial.defaultZone);
62
+ config = {
63
+ ...config,
64
+ ...partial,
65
+ ...(defaultZone === undefined ? {} : { defaultZone }),
66
+ };
50
67
  return config;
51
68
  }
52
69
 
@@ -4,10 +4,9 @@
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
8
  import { type CronExpression, parseCronOnce } from './cron-parse';
8
9
  import { cronNotDescribable, localeInvalid } from './errors';
9
- import { cachedFormatter } from './intl-cache';
10
- import { canonicalLocale } from './locale-canonical';
11
10
 
12
11
  export interface CronPhrases {
13
12
  everyMinute: string;
@@ -134,8 +133,9 @@ function fill(template: string, vars: Readonly<Record<string, string | number>>)
134
133
  * header. `canonicalLocale` collapses the spellings of one locale — `EN-us`, `en-latn-us` — but it
135
134
  * still returns a distinct string for every unknown `-u-` extension value, so the key alone does
136
135
  * not bound anything and only the cap keeps the key space finite. Neither half is redundant. The
137
- * cap and its FIFO live in `intl-cache.ts`, because `zones.ts` and `format.ts` needed the same
138
- * rule and a hazard documented in one file is a hazard the other two repeat.
136
+ * cap and its FIFO live in `@ultimat3/core`'s `intl-cache.ts`, because `zones.ts`, `format.ts` and
137
+ * `@ultimat3/money`'s formatter all need the same rule, and a hazard documented in one file is a
138
+ * hazard every other one repeats.
139
139
  *
140
140
  * Both caches are fed the canonical `tag` by `describeCron` alone, never a caller string.
141
141
  */
@@ -106,9 +106,12 @@ export function nextCronOccurrence(
106
106
  carry(cursor);
107
107
  }
108
108
 
109
+ // The backstop, not the primary check. An impossible day/month pair — `0 0 30 2 *`, a 30th of
110
+ // February — is refused by `parseCron` in constant time, because reaching it here cost ~150ms of
111
+ // blocking CPU per call and `firedSince` pays that per tick of the scheduler's leader loop.
109
112
  throw cronInvalid(
110
113
  typeof expression === 'string' ? expression : cron.source,
111
- `no occurrence after ${MAX_STEPS} search steps — the date fields can never all match (e.g. "0 0 30 2 *", a 30th of February)`,
114
+ `no occurrence after ${MAX_STEPS} search steps — the date fields can never all match`,
112
115
  );
113
116
  }
114
117
 
package/src/cron-parse.ts CHANGED
@@ -20,15 +20,20 @@ export interface CronExpression {
20
20
  dayOfWeekRestricted: boolean;
21
21
  }
22
22
 
23
- const MACROS: Readonly<Record<string, string>> = {
24
- '@yearly': '0 0 1 1 *',
25
- '@annually': '0 0 1 1 *',
26
- '@monthly': '0 0 1 * *',
27
- '@weekly': '0 0 * * 0',
28
- '@daily': '0 0 * * *',
29
- '@midnight': '0 0 * * *',
30
- '@hourly': '0 * * * *',
31
- };
23
+ // A `Map`, not an object literal: the key is a caller's string, and `MACROS['constructor']` on a
24
+ // plain object answers `Object` — a function that reached `.split()` as a bare `TypeError` out of
25
+ // the one function whose entire contract is a coded refusal.
26
+ const MACROS: ReadonlyMap<string, string> = new Map(
27
+ Object.entries({
28
+ '@yearly': '0 0 1 1 *',
29
+ '@annually': '0 0 1 1 *',
30
+ '@monthly': '0 0 1 * *',
31
+ '@weekly': '0 0 * * 0',
32
+ '@daily': '0 0 * * *',
33
+ '@midnight': '0 0 * * *',
34
+ '@hourly': '0 * * * *',
35
+ }),
36
+ );
32
37
 
33
38
  const MONTH_NAMES = [
34
39
  'jan',
@@ -49,7 +54,7 @@ const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
49
54
  /** Parse 5 fields (`m h dom mon dow`) or 6 with a leading seconds field. */
50
55
  export function parseCron(expression: string): CronExpression {
51
56
  const trimmed = expression.trim().toLowerCase();
52
- const expanded = MACROS[trimmed] ?? trimmed;
57
+ const expanded = MACROS.get(trimmed) ?? trimmed;
53
58
  const fields = expanded.split(/\s+/).filter((field) => field !== '');
54
59
 
55
60
  if (fields.length !== 5 && fields.length !== 6) {
@@ -65,12 +70,14 @@ export function parseCron(expression: string): CronExpression {
65
70
  const hours = parseField(expression, hourField ?? '*', 0, 23);
66
71
  const daysOfMonth = parseField(expression, domField ?? '*', 1, 31);
67
72
  const months = parseField(expression, monthField ?? '*', 1, 12, MONTH_NAMES, 1);
68
- const rawDow = parseField(expression, dowField ?? '*', 0, 7, DAY_NAMES, 0);
73
+ // Span 7, not `max - min + 1` = 8: the dow field accepts 0-7 because Sunday has two spellings,
74
+ // so the modulus a wrap strides over is a week with a phantom day in it unless it is stated.
75
+ const rawDow = parseField(expression, dowField ?? '*', 0, 7, DAY_NAMES, 0, 7);
69
76
 
70
77
  // 0 and 7 are both Sunday in cron; ISO calls Sunday 7.
71
78
  const daysOfWeek = [...new Set(rawDow.map((day) => (day === 0 ? 7 : day)))].sort((a, b) => a - b);
72
79
 
73
- return {
80
+ const parsed: CronExpression = {
74
81
  source: expanded,
75
82
  seconds,
76
83
  minutes,
@@ -81,6 +88,52 @@ export function parseCron(expression: string): CronExpression {
81
88
  dayOfMonthRestricted: isRestricted(domField ?? '*'),
82
89
  dayOfWeekRestricted: isRestricted(dowField ?? '*'),
83
90
  };
91
+ assertReachableDate(expression, parsed);
92
+ return parsed;
93
+ }
94
+
95
+ /** Longest each month can be. February is 29 because leap years happen — 29 CAN fire, 30 cannot. */
96
+ const LONGEST_MONTH: readonly number[] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
97
+
98
+ const MONTH_LABELS = [
99
+ 'january',
100
+ 'february',
101
+ 'march',
102
+ 'april',
103
+ 'may',
104
+ 'june',
105
+ 'july',
106
+ 'august',
107
+ 'september',
108
+ 'october',
109
+ 'november',
110
+ 'december',
111
+ ];
112
+
113
+ /**
114
+ * A grammatically valid expression whose day and month fields can never both match — `0 0 30 2 *`,
115
+ * a 30th of February. Decidable from the parsed fields in a bounded 12 x 31 scan, and decided
116
+ * HERE, because the alternative was `nextCronOccurrence` exhausting its 200,000-step walk: ~150 ms
117
+ * of blocking CPU, paid by `firedSince` on every tick of the scheduler's leader loop, to reach a
118
+ * refusal that `isValidCron` had already answered `true` for.
119
+ *
120
+ * Only when day-of-month is restricted and day-of-week is NOT. Vixie's OR rule is why: with both
121
+ * restricted, `0 0 30 2 5` means "the 30th of February OR any Friday in February", which fires
122
+ * every Friday in February — refusing it would break a working schedule.
123
+ */
124
+ function assertReachableDate(expression: string, cron: CronExpression): void {
125
+ if (!cron.dayOfMonthRestricted || cron.dayOfWeekRestricted) return;
126
+ for (const month of cron.months) {
127
+ const longest = LONGEST_MONTH[month - 1] ?? 31;
128
+ for (const day of cron.daysOfMonth) {
129
+ if (day <= longest) return;
130
+ }
131
+ }
132
+ const months = cron.months.map((month) => MONTH_LABELS[month - 1] ?? String(month)).join(', ');
133
+ throw cronInvalid(
134
+ expression,
135
+ `day ${cron.daysOfMonth.join(',')} never occurs in ${months}, so this expression can never fire`,
136
+ );
84
137
  }
85
138
 
86
139
  export function isValidCron(expression: string): boolean {
@@ -113,6 +166,13 @@ function parseField(
113
166
  max: number,
114
167
  names: readonly string[] = [],
115
168
  nameOffset = 0,
169
+ /**
170
+ * How many distinct values one full turn of this field has — `max - min + 1` for every field
171
+ * whose spelling is one-to-one. Day-of-week is the exception and the reason this is a parameter:
172
+ * it spells Sunday twice (0 and 7), so its 0-7 bounds describe 8 slots over a 7-day week, and a
173
+ * wrapping stride computed from the bounds walked a day that does not exist.
174
+ */
175
+ span = max - min + 1,
116
176
  ): number[] {
117
177
  const values = new Set<number>();
118
178
  for (const part of field.split(',')) {
@@ -144,7 +204,6 @@ function parseField(
144
204
  // Wrapping ranges (`fri-mon`, `22-2`) are a real cron idiom, and the stride CONTINUES across
145
205
  // the wrap: `23-3/2` is 23, 01, 03 — every second hour starting at 23. Restarting at `min`
146
206
  // answered 23, 00, 02, an hour off for every occurrence past midnight.
147
- const span = max - min + 1;
148
207
  const length = to - from + span;
149
208
  for (let offset = 0; offset <= length; offset += step) {
150
209
  values.add(min + ((from - min + offset) % span));
package/src/duration.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * `step.sleep('3d')` in @ultimat3/jobs and every `retry.backoff` value comes through here.
4
4
  */
5
5
 
6
- import { durationInvalid } from './errors';
6
+ import { durationInvalid, scheduleInvalid } from './errors';
7
7
 
8
8
  export const MS = 1;
9
9
  export const SECOND = 1000;
@@ -65,8 +65,17 @@ export function toMs(duration: string | number): number {
65
65
  return typeof duration === 'number' ? duration : parseDuration(duration);
66
66
  }
67
67
 
68
+ /**
69
+ * `Math.round` breaks ties toward `+Infinity`, which is asymmetric across zero: `'1500ms'` was 2
70
+ * and `'-1500ms'` was -1, so a signed duration and its mirror did not answer mirrored seconds.
71
+ * The sign is carried out and the MAGNITUDE rounded — `packages/money/src/rounding.ts` is the
72
+ * framework's one statement of this, and `signed()` there is why zero never comes back as `-0`.
73
+ */
68
74
  export function toSeconds(duration: string | number): number {
69
- return Math.round(toMs(duration) / SECOND);
75
+ const ms = toMs(duration);
76
+ const seconds = Math.round(Math.abs(ms) / SECOND);
77
+ if (seconds === 0) return 0;
78
+ return ms < 0 ? -seconds : seconds;
70
79
  }
71
80
 
72
81
  export interface FormatDurationOptions {
@@ -95,6 +104,14 @@ export function formatDuration(
95
104
  ): string {
96
105
  const style = options.style ?? 'short';
97
106
  const maxUnits = options.maxUnits ?? 2;
107
+ // Refused, not clamped, for the reason `scheduleInvalid`'s own fix line gives: `maxUnits: 0`
108
+ // made `pieces.length >= maxUnits` true before the first unit was measured, so EVERY duration
109
+ // fell through to the zero fallback and 9,000,000 ms rendered as "0 sec". A caller that asked
110
+ // for no units wanted something this function cannot express, and a silently wrong number on a
111
+ // screen is worse than a failed render.
112
+ if (!Number.isInteger(maxUnits) || maxUnits < 1) {
113
+ throw scheduleInvalid('maxUnits', maxUnits, 'at least 1');
114
+ }
98
115
  let remaining = Math.abs(Math.round(ms));
99
116
  const pieces: string[] = [];
100
117
 
@@ -141,13 +158,25 @@ function parseIso8601Duration(body: string, original: string): number {
141
158
  const match = ISO_8601.exec(body);
142
159
  if (match === null) throw durationInvalid(original);
143
160
  const [, weeks, days, hours, minutes, seconds] = match;
161
+ // The guard is on the GROUPS, not on the total. `'P'` and `'PT'` name no duration and must be
162
+ // refused; `'PT0S'` — the canonical zero most emitters write — names one, and testing
163
+ // `total === 0` rejected it along with `'PT0H0M0S'` and `'P0W'` while `'P0D'` was let through
164
+ // by a special case. `ISO_8601` already requires a component group for any body past bare `P`.
165
+ if (
166
+ weeks === undefined &&
167
+ days === undefined &&
168
+ hours === undefined &&
169
+ minutes === undefined &&
170
+ seconds === undefined
171
+ ) {
172
+ throw durationInvalid(original);
173
+ }
144
174
  const total =
145
175
  number(weeks) * WEEK +
146
176
  number(days) * DAY +
147
177
  number(hours) * HOUR +
148
178
  number(minutes) * MINUTE +
149
179
  number(seconds) * SECOND;
150
- if (total === 0 && body.toUpperCase() !== 'P0D') throw durationInvalid(original);
151
180
  return Math.round(total);
152
181
  }
153
182
 
package/src/format.ts CHANGED
@@ -4,9 +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
8
  import { differenceMs, type Instant } from './instant';
8
- import { cachedFormatter } from './intl-cache';
9
- import { canonicalLocale } from './locale-canonical';
9
+ import { isoDateInZone } from './zoned';
10
10
  import { assertTimeZone, type TimeZone } from './zones';
11
11
 
12
12
  export type DateTimeStyle = 'short' | 'medium' | 'long' | 'full';
@@ -87,15 +87,17 @@ export function formatWithOffset(at: Instant, options: FormatDateTimeOptions): s
87
87
  return offset === '' ? text : `${text} (${offset})`;
88
88
  }
89
89
 
90
- /** ISO-8601 date parts in a zone, for `<input type="date">` and CSV columns. */
90
+ /**
91
+ * ISO-8601 date parts in a zone, for `<input type="date">` and CSV columns.
92
+ *
93
+ * Built from `isoDateInZone`, not from `Intl`: `year: 'numeric'` neither zero-pads a year below
94
+ * 1000 nor carries the era, so this answered `'50-01-01'` where `isoDateInZone` answered
95
+ * `'0050-01-01'` — two functions in one package answering one question differently, and the short
96
+ * form matches no ISO pattern and is rejected by the very input this exists for. One padding rule,
97
+ * in one place.
98
+ */
91
99
  export function formatIsoDate(at: Instant, zone: TimeZone): string {
92
- const parts = formatterFor('en-CA', {
93
- timeZone: assertTimeZone(zone),
94
- year: 'numeric',
95
- month: '2-digit',
96
- day: '2-digit',
97
- }).format(at);
98
- return parts.replace(/\//g, '-');
100
+ return isoDateInZone(at, assertTimeZone(zone));
99
101
  }
100
102
 
101
103
  export interface FormatRelativeOptions extends Omit<FormatContext, 'zone'> {
@@ -156,12 +158,18 @@ const ORDINAL_SUFFIX: Record<Intl.LDMLPluralRule, string> = {
156
158
  };
157
159
 
158
160
  /**
159
- * `Intl` renders `November 5, 2011`, never `5th of November`. When a design asks for the
160
- * ordinal, build it from `Intl.PluralRules` with `type: 'ordinal'` — English-only by
161
- * nature, which is why it is a helper and not the default date format.
161
+ * `Intl` renders `November 5, 2011`, never `5th of November`. When a design asks for the ordinal,
162
+ * build it from `Intl.PluralRules` with `type: 'ordinal'` — **English only**, which is why it is a
163
+ * helper and not the default date format.
164
+ *
165
+ * It takes NO locale, and that is the enforcement rather than a note. It used to accept one, pick
166
+ * the plural category with it, and then append the ENGLISH suffix for that category: `ordinal(1,
167
+ * 'de')` was `'1th'`, a word in no language. A parameter that cannot change the answer correctly
168
+ * is removed, so a caller who wants a localized ordinal finds out from `tsc` instead of from a
169
+ * rendered page. **Breaking: the `locale` parameter is gone.**
162
170
  */
163
- export function ordinal(value: number, locale = 'en'): string {
164
- const category = new Intl.PluralRules(locale, { type: 'ordinal' }).select(value);
171
+ export function ordinal(value: number): string {
172
+ const category = new Intl.PluralRules('en', { type: 'ordinal' }).select(value);
165
173
  return `${value}${ORDINAL_SUFFIX[category]}`;
166
174
  }
167
175
 
package/src/index.ts CHANGED
@@ -97,6 +97,21 @@ export {
97
97
  toIso,
98
98
  toIsoDateUtc,
99
99
  } from './instant';
100
+ export {
101
+ addPlainDays,
102
+ comparePlainDates,
103
+ isPlainDate,
104
+ PLAIN_DATE_PATTERN,
105
+ type PlainDate,
106
+ type PlainDateParts,
107
+ plainDate,
108
+ plainDateIn,
109
+ plainDateOf,
110
+ plainDateParts,
111
+ plainDateToUtcInstant,
112
+ plainDateUtc,
113
+ plainDaysBetween,
114
+ } from './plain-date';
100
115
  export {
101
116
  type LocalSlot,
102
117
  nextLocalSlot,
package/src/instant.ts CHANGED
@@ -24,8 +24,20 @@ export function instant(value: Date): Instant {
24
24
  return new Date(value.getTime()) as Instant;
25
25
  }
26
26
 
27
- /** ISO-8601 in, `Instant` out. An offset or `Z` is required — a bare local string is a bug. */
27
+ /**
28
+ * A time of day, and the zone it is stated in. `2026-03-14T09:00` without one is resolved by
29
+ * `new Date` through the PROCESS's zone, so the guard has to see both halves: a string carrying
30
+ * a clock time is refused unless it also carries `Z` or an offset. A date-only form carries no
31
+ * clock time and is UTC by specification, so it passes.
32
+ */
33
+ const CLOCK_TIME = /[t ]\d{1,2}:\d{2}/i;
34
+ const UTC_OFFSET = /(?:z|[+-]\d{2}:?\d{2})$/i;
35
+
36
+ /** ISO-8601 in, `Instant` out. An offset or `Z` is required — a bare local string is refused. */
28
37
  export function fromIso(iso: string): Instant {
38
+ // Enforced, not documented: this header asked for an offset for three releases while the body
39
+ // accepted a bare local string and answered a different instant per deployment timezone.
40
+ if (CLOCK_TIME.test(iso) && !UTC_OFFSET.test(iso)) throw instantInvalid(iso);
29
41
  const parsed = new Date(iso);
30
42
  if (Number.isNaN(parsed.getTime())) throw instantInvalid(iso);
31
43
  return parsed as Instant;
@@ -41,9 +53,17 @@ export function toIsoDateUtc(at: Instant): string {
41
53
  return at.toISOString().slice(0, 10);
42
54
  }
43
55
 
56
+ /**
57
+ * The SAME test `instant()` performs, and for the same reason. `Number.isFinite` is not the `Date`
58
+ * range: `+/-8.64e15` ms is the limit, so a finite `1e16` produced an Invalid Date branded as an
59
+ * `Instant` — a value `isInstant` answers `false` for (the type's own predicate rejecting what its
60
+ * constructor certified) and `toIso` throws a bare `RangeError` out of. Reached through
61
+ * `fromEpochSeconds`, `addMs`, `subtractMs` and `now(clock)`, none of which re-check.
62
+ */
44
63
  export function fromEpochMs(ms: number): Instant {
45
- if (!Number.isFinite(ms)) throw instantInvalid(String(ms));
46
- return new Date(ms) as Instant;
64
+ const at = new Date(ms);
65
+ if (Number.isNaN(at.getTime())) throw instantInvalid(String(ms));
66
+ return at as Instant;
47
67
  }
48
68
 
49
69
  export function toEpochMs(at: Instant): number {
@@ -105,7 +125,11 @@ export function epoch(): Instant {
105
125
  return new Date(0) as Instant;
106
126
  }
107
127
 
108
- /** Tolerates a `Clock` whose `now()` returns either a `Date` or epoch milliseconds. */
128
+ /**
129
+ * `Clock.now()` returns a `Date`, so the number branch is unreachable through the typed API. It is
130
+ * kept for the untyped caller: a JS `Clock` answering epoch milliseconds would otherwise reach
131
+ * `.getTime()` on a number and throw a bare `TypeError` instead of `X_INSTANT_INVALID`.
132
+ */
109
133
  function epochMsOf(value: Date | number): number {
110
134
  return typeof value === 'number' ? value : value.getTime();
111
135
  }
@@ -0,0 +1,133 @@
1
+ // A calendar date: a year, a month and a day, with no time and therefore no zone. The framework's
2
+ // "never format a date without an IANA zone" rule is about INSTANTS — a `PlainDate` needs no zone
3
+ // because it names no instant, and that is the whole reason it exists: `effective_on` is the date
4
+ // a rate applies, not a moment, and storing it as a `timestamptz` makes it a different date either
5
+ // side of midnight for half the planet.
6
+
7
+ import { scheduleInvalid } from './errors';
8
+ import { type Instant, instant } from './instant';
9
+ import { isoDateInZone } from './zoned';
10
+ import type { TimeZone } from './zones';
11
+
12
+ declare const plainDateBrand: unique symbol;
13
+
14
+ /**
15
+ * `2026-03-14`. A branded STRING, not an object and not a `Date`, and each half of that is load-
16
+ * bearing:
17
+ *
18
+ * - not a `Date`, because a `Date` is an instant: read back through the local zone,
19
+ * `2026-03-14T00:00:00Z` is the 13th anywhere west of Greenwich, and binding one to a Postgres
20
+ * `date` parameter fails outright (`time zone "gmt-0500" not recognized`, measured on 17.10).
21
+ * - a string, because the ISO form sorts lexicographically exactly as it sorts chronologically,
22
+ * round-trips through `JSON.stringify` as itself, and is the literal Postgres accepts and
23
+ * returns for a `date` column.
24
+ */
25
+ export type PlainDate = string & { readonly [plainDateBrand]: 'plain-date' };
26
+
27
+ /** The shape a CHECK constraint and a JSON Schema both spell. ECMAScript and POSIX ERE agree. */
28
+ export const PLAIN_DATE_PATTERN = '^\\d{4}-\\d{2}-\\d{2}$';
29
+
30
+ const SHAPE = /^(\d{4})-(\d{2})-(\d{2})$/;
31
+
32
+ export interface PlainDateParts {
33
+ readonly year: number;
34
+ readonly month: number;
35
+ readonly day: number;
36
+ }
37
+
38
+ const pad = (value: number, width: number): string => String(value).padStart(width, '0');
39
+
40
+ /** Days in a month, Gregorian. February is the only interesting one. */
41
+ const daysInMonth = (year: number, month: number): number =>
42
+ month === 2
43
+ ? (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
44
+ ? 29
45
+ : 28
46
+ : [4, 6, 9, 11].includes(month)
47
+ ? 30
48
+ : 31;
49
+
50
+ /**
51
+ * The parse every other function here goes through. `null` rather than a throw, so the guard and
52
+ * the thrower share one rule and only the failure policy differs — the same split
53
+ * `resolveEnvironment` / `tryResolveEnvironment` make in core.
54
+ */
55
+ const read = (value: unknown): PlainDateParts | null => {
56
+ if (typeof value !== 'string') return null;
57
+ const match = SHAPE.exec(value);
58
+ if (match === null) return null;
59
+ const year = Number(match[1]);
60
+ const month = Number(match[2]);
61
+ const day = Number(match[3]);
62
+ if (month < 1 || month > 12) return null;
63
+ // A day the month does not have is the case a regex cannot see, and it is the one that matters:
64
+ // `2026-02-30` reaches Postgres as `date` input and is rejected there, three layers later.
65
+ if (day < 1 || day > daysInMonth(year, month)) return null;
66
+ return { year, month, day };
67
+ };
68
+
69
+ export const isPlainDate = (value: unknown): value is PlainDate => read(value) !== null;
70
+
71
+ /** A calendar date from its ISO form. Throws on anything that is not one — including `2026-02-30`. */
72
+ export function plainDate(value: string): PlainDate {
73
+ const parts = read(value);
74
+ if (parts === null) {
75
+ throw scheduleInvalid('date', value, 'a real YYYY-MM-DD calendar date');
76
+ }
77
+ return value as PlainDate;
78
+ }
79
+
80
+ /** A calendar date from its fields. Out-of-range fields throw rather than wrap into another month. */
81
+ export function plainDateOf(parts: PlainDateParts): PlainDate {
82
+ return plainDate(`${pad(parts.year, 4)}-${pad(parts.month, 2)}-${pad(parts.day, 2)}`);
83
+ }
84
+
85
+ export function plainDateParts(date: PlainDate): PlainDateParts {
86
+ const parts = read(date);
87
+ if (parts === null) throw scheduleInvalid('date', date, 'a real YYYY-MM-DD calendar date');
88
+ return parts;
89
+ }
90
+
91
+ /**
92
+ * The calendar date an instant falls on **in a named zone** — the one conversion between the two,
93
+ * and it takes a zone because there is no other honest way to make it. 09:00 UTC on the 14th is
94
+ * still the 13th in Los Angeles.
95
+ */
96
+ export const plainDateIn = (at: Instant, zone: TimeZone): PlainDate =>
97
+ isoDateInZone(at, zone) as PlainDate;
98
+
99
+ /**
100
+ * The calendar date a `Date` holds when read as UTC. For ONE caller: a Postgres driver hands a
101
+ * `date` column back as a `Date` at UTC midnight (measured: Bun's `sql` and PGlite both), so the
102
+ * date the column holds is its UTC date and reading it through the local zone loses a day west of
103
+ * Greenwich. Never use this on a timestamp — that is `plainDateIn`, which asks for the zone.
104
+ */
105
+ export function plainDateUtc(at: Date): PlainDate {
106
+ const checked = instant(at);
107
+ return `${pad(checked.getUTCFullYear(), 4)}-${pad(checked.getUTCMonth() + 1, 2)}-${pad(
108
+ checked.getUTCDate(),
109
+ 2,
110
+ )}` as PlainDate;
111
+ }
112
+
113
+ /** Midnight UTC of the date, as an instant — the inverse of `plainDateUtc`, and never of `plainDateIn`. */
114
+ export const plainDateToUtcInstant = (date: PlainDate): Instant =>
115
+ instant(new Date(`${plainDate(date)}T00:00:00.000Z`));
116
+
117
+ /** `-1`, `0`, `1`. Lexicographic order IS chronological order for this form; that is why it sorts. */
118
+ export const comparePlainDates = (left: PlainDate, right: PlainDate): number =>
119
+ left < right ? -1 : left > right ? 1 : 0;
120
+
121
+ const DAY_MS = 86_400_000;
122
+
123
+ /** Whole days added, over UTC midnights, so no DST rule and no zone can shorten a day here. */
124
+ export function addPlainDays(date: PlainDate, days: number): PlainDate {
125
+ if (!Number.isSafeInteger(days)) {
126
+ throw scheduleInvalid('days', days, 'a whole number of days');
127
+ }
128
+ return plainDateUtc(new Date(plainDateToUtcInstant(date).getTime() + days * DAY_MS));
129
+ }
130
+
131
+ /** Signed whole days from `from` to `to`. Always integral: both ends are UTC midnights. */
132
+ export const plainDaysBetween = (from: PlainDate, to: PlainDate): number =>
133
+ (plainDateToUtcInstant(to).getTime() - plainDateToUtcInstant(from).getTime()) / DAY_MS;
@@ -4,7 +4,7 @@
4
4
  * A 13-letter name has 2^12 casings and a request header can name any of them.
5
5
  */
6
6
 
7
- import { cachedFormatter } from './intl-cache';
7
+ import { cachedFormatter } from '@ultimat3/core';
8
8
 
9
9
  /** ES2024 `Intl` accepts `+01:00` as a zone; we do not — a fixed offset has no DST rules. */
10
10
  const NUMERIC_OFFSET = /^[+-]/;
package/src/zones.ts CHANGED
@@ -4,9 +4,9 @@
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
8
  import { timezoneInvalid } from './errors';
8
9
  import type { Instant } from './instant';
9
- import { cachedFormatter } from './intl-cache';
10
10
  import { canonicalTimeZone } from './zone-canonical';
11
11
 
12
12
  /** An IANA identifier: `Europe/Berlin`, `Asia/Kathmandu`, `UTC`. Never `CET`, never `+01:00`. */
@@ -117,13 +117,22 @@ export function zoneAbbrev(
117
117
  locale = 'en-US',
118
118
  style: 'short' | 'long' | 'shortOffset' | 'longOffset' = 'short',
119
119
  ): string {
120
- const formatter = new Intl.DateTimeFormat(locale, {
121
- timeZone: zone,
122
- timeZoneName: style,
123
- hourCycle: 'h23',
120
+ 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;
124
+ // The one `Intl` construction in this package that escaped the shared cache: it built a formatter
125
+ // per call on the caller's raw zone and locale, so an `x-timezone` an app renders a label from
126
+ // paid for a fresh `Intl.DateTimeFormat` every time and an unknown one escaped as a `RangeError`.
127
+ const formatter = cachedFormatter(labelFormatters, `${canonical}|${tag}|${style}`, () => {
128
+ return new Intl.DateTimeFormat(tag, {
129
+ timeZone: canonical,
130
+ timeZoneName: style,
131
+ hourCycle: 'h23',
132
+ });
124
133
  });
125
134
  const label = formatter.formatToParts(at).find((part) => part.type === 'timeZoneName')?.value;
126
- return label ?? offsetLabel(offsetAt(zone, at));
135
+ return label ?? offsetLabel(offsetAt(canonical, at));
127
136
  }
128
137
 
129
138
  /**
@@ -145,6 +154,7 @@ export function observesDst(zone: TimeZone, at: Instant): boolean {
145
154
  }
146
155
 
147
156
  const formatters = new Map<string, Intl.DateTimeFormat>();
157
+ const labelFormatters = new Map<string, Intl.DateTimeFormat>();
148
158
 
149
159
  function partsFormatterFor(zone: TimeZone): Intl.DateTimeFormat {
150
160
  // Keyed on the canonical name, so 4,096 casings of one zone are one entry rather than 4,096.
package/src/intl-cache.ts DELETED
@@ -1,26 +0,0 @@
1
- /**
2
- * One bounded cache for every `Intl` formatter this package builds.
3
- * A locale and a zone both arrive from a request header, so an unbounded `Map` keyed on that
4
- * string is memory the client chooses: 4,096 case-variants of one zone name retained 31 MB,
5
- * ~7.7 KB per `Intl.DateTimeFormat`, and 600 zones times 2^12 casings has no ceiling at all.
6
- */
7
-
8
- /**
9
- * Above the full canonical IANA set (445 zones as of tzdata 2025) so a correct app never evicts,
10
- * and small enough that the worst case is a few megabytes rather than a leak. A miss costs one
11
- * `Intl` construction, never a wrong answer — which is what makes a bound safe here at all.
12
- */
13
- export const MAX_CACHED_FORMATTERS = 512;
14
-
15
- /** FIFO — a `Map` iterates in insertion order, so the first key inserted is the first evicted. */
16
- export function cachedFormatter<T>(cache: Map<string, T>, key: string, build: () => T): T {
17
- const hit = cache.get(key);
18
- if (hit !== undefined) return hit;
19
- const formatter = build();
20
- if (cache.size >= MAX_CACHED_FORMATTERS) {
21
- const oldest = cache.keys().next().value;
22
- if (oldest !== undefined) cache.delete(oldest);
23
- }
24
- cache.set(key, formatter);
25
- return formatter;
26
- }
@@ -1,23 +0,0 @@
1
- /**
2
- * One locale, one key. `Intl` accepts `EN-us`, `en-US` and `en-latn-us` as the same locale, so a
3
- * cache keyed on the caller's spelling holds three formatters where one would do — and the caller
4
- * is `Accept-Language`. The twin of `zone-canonical.ts`, for the other header-supplied string.
5
- */
6
-
7
- /**
8
- * The canonical BCP 47 spelling, or `undefined` when the tag is not structurally valid at all
9
- * (`en_US`, `''`, `not a locale`). Well-formed but unknown to ICU (`zz`) is a locale — `Intl`
10
- * falls back for it, and refusing here would be stricter than the formatters this feeds.
11
- *
12
- * Deliberately **not** memoised: this is string work, and a `Map` keyed on a header value is the
13
- * unbounded cache the whole `intl-cache.ts` bound exists to prevent.
14
- */
15
- export function canonicalLocale(locale: string): string | undefined {
16
- try {
17
- // `getCanonicalLocales` runs the same IsStructurallyValidLanguageTag check that
18
- // `supportedLocalesOf` throws on, and unlike it, hands back the canonical spelling.
19
- return Intl.getCanonicalLocales(locale)[0];
20
- } catch {
21
- return undefined;
22
- }
23
- }