@ultimat3/time 16.0.0 → 17.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,7 +12,6 @@
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` |
16
15
  | `duration.ts` | `'2h30m'` ⇄ ms |
17
16
  | `cron.ts` | barrel over the three cron modules — the only one `index.ts` re-exports |
18
17
  | `cron-parse.ts` | field grammar → `CronExpression`. Non-integer, non-name tokens are rejected. |
@@ -52,14 +51,17 @@
52
51
  a code that has shipped since 1.0, with a runnable `fix:`, thrown by exactly one of nine callers.
53
52
  `format.ts` argued the pass-through decided "a cache key, never whether a locale is acceptable",
54
53
  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
54
+ 9.x.** `assertLocale` (**`@ultimat3/core`'s**, since 16.x — `locale.ts` is gone, because
55
+ `@ultimat3/money` needs the identical screen and tier 1 may not import sideways) is the one
56
+ screen and it VALIDATES AND CANONICALIZES in one
56
57
  step — `Intl.getCanonicalLocales` runs the same structural check `supportedLocalesOf` throws on
57
58
  and hands back the spelling the cache keys on, so there is no second question to ask. Well-formed
58
59
  but unknown to ICU (`zz`) is **not** refused: `Intl` falls back, and so must a rendered page.
59
60
  - **Never cache an `Intl` formatter on a raw caller string.** A zone and a locale both arrive from
60
61
  a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `assertLocale`
61
62
  for a locale) and the cache must be bounded (`cachedFormatter`). **`cachedFormatter`,
62
- `MAX_CACHED_FORMATTERS` and `canonicalLocale` are `@ultimat3/core`'s as of 2.0.0**, not this
63
+ `MAX_CACHED_FORMATTERS`, `canonicalLocale` and `assertLocale` are `@ultimat3/core`'s** — the
64
+ first three as of 2.0.0 and the screen as of 16.x, with `X_LOCALE_INVALID` moving with it — not this
63
65
  package's: `@ultimat3/money` hit the identical unbounded-`Map`-on-a-header bug and tier 1 may not
64
66
  import sideways, so the mechanism moved down a tier rather than being copied. An unbounded
65
67
  `Map` keyed on `x-timezone` grew 31 MB for 4,096 casings of one zone name, and the casing space
@@ -141,6 +143,19 @@
141
143
  - Never take the clock from `Date.now()`; accept a `Clock` (`now(clock)`).
142
144
  - Cron and schedules iterate the **local wall clock**, then convert once with `fromZoned`.
143
145
  - `m` is minutes, `ms` is milliseconds. A bare number is not a duration.
146
+ - **`toMs`'s NUMBER arm is screened, `As of 2026-08-26`** — `finiteOption('toMs', 'duration', …)`.
147
+ The string arm has always been total, because `parseDuration` refuses everything it cannot read;
148
+ the number arm passed straight through, so `toMs(Number(process.env.TTL_MS))` on an unset
149
+ variable answered `NaN` and every `wakeAt > now` built from it read false forever — a sleep that
150
+ never ends and a timeout that never fires, with no error anywhere. `??` does not guard it: `NaN`
151
+ is not nullish. `@ultimat3/notify`'s `toDurationMs` had screened its own copy of the body and
152
+ this one had not, so one duration vocabulary gave two answers to one input (#372);
153
+ `packages/notify/src/plan-bounds.test.ts` calls BOTH on the same inputs and is what keeps them
154
+ from drifting apart again.
155
+ **`finiteOption`, never `finiteCount`**: a duration here is legitimately NEGATIVE —
156
+ `parseDuration` accepts a leading `-`, and `toSeconds(-3000)` is a tested `-3` — and legitimately
157
+ fractional. A caller that needs whole non-negative milliseconds narrows on top; narrowing here
158
+ breaks the signed-duration contract `toSeconds` is built on.
144
159
  - Tests must cover a spring-forward gap, a fall-back overlap and a non-hour offset zone.
145
160
 
146
161
  ## Commands
package/README.md CHANGED
@@ -154,7 +154,7 @@ negated; an empty one is `0` in either direction, never `-0`.
154
154
  | `X_DST_AMBIGUOUS` | overlap hit with `overlap: 'throw'` |
155
155
  | `X_DST_NONEXISTENT` | gap hit with `gap: 'throw'` |
156
156
  | `X_INSTANT_INVALID` | unparseable timestamp |
157
- | `X_LOCALE_INVALID` | a tag `Intl` cannot parse (`en_US`, `''`) reached `describeCron` |
157
+ | `X_LOCALE_INVALID` | a tag `Intl` cannot parse (`en_US`, `''`) reached any entry point taking a `locale`. Declared by `@ultimat3/core` `As of 2026-08`, thrown here unchanged — `@ultimat3/time` owned it until then. It answers **400**, not 500: the `locale` stage negotiates `Accept-Language` and never throws, so a tag that reaches this code came from a path, query or action input the caller wrote |
158
158
  | `X_CRON_NOT_DESCRIBABLE` | a valid 6-field cron whose seconds field `CronPhrases` has no words for |
159
159
  | `X_SCHEDULE_INVALID` | a wall-clock field out of range: `slot.hour`, `slot.minute`, `slot.second`, `slot.weekday` |
160
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/time",
3
- "version": "16.0.0",
3
+ "version": "17.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": "16.0.0"
37
+ "@ultimat3/core": "17.0.0"
38
38
  }
39
39
  }
@@ -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 } from '@ultimat3/core';
7
+ import { assertLocale, cachedFormatter } from '@ultimat3/core';
8
8
  import { type CronExpression, parseCronOnce } from './cron-parse';
9
9
  import { cronNotDescribable } from './errors';
10
- import { assertLocale } from './locale';
11
10
 
12
11
  export interface CronPhrases {
13
12
  everyMinute: string;
package/src/duration.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  * `step.sleep('3d')` in @ultimat3/jobs and every `retry.backoff` value comes through here.
4
4
  */
5
5
 
6
+ import { assertLocale, finiteOption } from '@ultimat3/core';
6
7
  import { durationInvalid, scheduleInvalid } from './errors';
7
- import { assertLocale } from './locale';
8
8
 
9
9
  export const MS = 1;
10
10
  export const SECOND = 1000;
@@ -61,9 +61,25 @@ export function parseDuration(input: string): number {
61
61
  return (negative ? -1 : 1) * Math.round(total);
62
62
  }
63
63
 
64
- /** Parse-or-passthrough for APIs that accept either form. */
64
+ /**
65
+ * Parse-or-passthrough for APIs that accept either form.
66
+ *
67
+ * The STRING arm has always been total — `parseDuration` refuses everything it cannot read. The
68
+ * NUMBER arm was the hole: it passed straight through, so `toMs(Number(process.env.TTL_MS))` on an
69
+ * unset variable answered `NaN`, and every `wakeAt > now` built from it reads false forever — a
70
+ * sleep that never ends, a timeout that never fires, no error anywhere. `??` does not guard it,
71
+ * because `NaN` is not nullish. `@ultimat3/notify` screened its own copy of this body and this one
72
+ * did not, so one duration vocabulary gave two answers to one input.
73
+ *
74
+ * `finiteOption`, not `finiteCount`: a duration here is legitimately NEGATIVE — `parseDuration`
75
+ * accepts a leading `-` and `toSeconds(-3000)` is a tested `-3` — and legitimately fractional.
76
+ * A caller that needs whole non-negative milliseconds narrows on top of this, which is what
77
+ * `@ultimat3/notify`'s `toDurationMs` does; narrowing here would break both.
78
+ */
65
79
  export function toMs(duration: string | number): number {
66
- return typeof duration === 'number' ? duration : parseDuration(duration);
80
+ return typeof duration === 'number'
81
+ ? finiteOption('toMs', 'duration', duration)
82
+ : parseDuration(duration);
67
83
  }
68
84
 
69
85
  /**
package/src/errors.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * The X_* error codes owned by @ultimat3/time.
3
3
  * DST ambiguity is a real state of the world, so it gets a code instead of a guess.
4
+ *
5
+ * `X_LOCALE_INVALID` is NOT here: it moved to `@ultimat3/core` beside `assertLocale`, because
6
+ * `@ultimat3/money` needs the same screen and tier 1 may not import sideways. A code has exactly
7
+ * one declaration — a second `registerErrorCodes` claim is `X_ERROR_CODE_DUPLICATE`.
4
8
  */
5
9
 
6
10
  import { registerErrorCodes, renderCauseValue, UltimateError } from '@ultimat3/core';
@@ -13,7 +17,6 @@ export const TIME_ERROR_CODES = [
13
17
  'X_DST_NONEXISTENT',
14
18
  'X_INSTANT_INVALID',
15
19
  'X_SCHEDULE_INVALID',
16
- 'X_LOCALE_INVALID',
17
20
  'X_CRON_NOT_DESCRIBABLE',
18
21
  ] as const;
19
22
 
@@ -27,7 +30,6 @@ export const TIME_ERROR_TITLES: Readonly<Record<TimeErrorCode, string>> = {
27
30
  X_DST_NONEXISTENT: 'the local time does not exist',
28
31
  X_INSTANT_INVALID: 'not a parseable instant',
29
32
  X_SCHEDULE_INVALID: 'a wall-clock field is out of range',
30
- X_LOCALE_INVALID: 'not a well-formed BCP 47 tag',
31
33
  X_CRON_NOT_DESCRIBABLE: 'a valid cron expression describeCron has no vocabulary for',
32
34
  };
33
35
 
@@ -123,19 +125,6 @@ export function dstNonexistent(wall: string, zone: string): TimeError {
123
125
  });
124
126
  }
125
127
 
126
- /**
127
- * A tag `Intl` cannot parse. Distinct from i18n's `X_LOCALE_UNSUPPORTED`, which is a
128
- * well-formed tag outside the app's supported set — this one is not a tag at all, and a raw
129
- * `RangeError` from a formatter says nothing about which caller supplied it.
130
- */
131
- export function localeInvalid(locale: string): TimeError {
132
- return new TimeError({
133
- code: 'X_LOCALE_INVALID',
134
- cause: `"${locale}" is not a well-formed BCP 47 language tag`,
135
- fix: "pass a tag like 'en', 'en-GB' or 'de-DE' — screen a header-supplied value with Intl.DateTimeFormat.supportedLocalesOf([tag]) before it reaches a formatter",
136
- });
137
- }
138
-
139
128
  export function instantInvalid(input: string): TimeError {
140
129
  return new TimeError({
141
130
  code: 'X_INSTANT_INVALID',
package/src/format.ts CHANGED
@@ -4,9 +4,8 @@
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 } from '@ultimat3/core';
7
+ import { assertLocale, cachedFormatter } from '@ultimat3/core';
8
8
  import { differenceMs, type Instant } from './instant';
9
- import { assertLocale } from './locale';
10
9
  import { isoDateInZone } from './zoned';
11
10
  import { assertTimeZone, type TimeZone } from './zones';
12
11
 
package/src/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  /** Public surface of @ultimat3/time. Explicit exports only. */
2
2
 
3
+ // `localeInvalid` and the `X_LOCALE_INVALID` it carries are `@ultimat3/core`'s since 16.x —
4
+ // `@ultimat3/money` needs the same screen and `money -> time` is a sideways import. Re-exported
5
+ // rather than dropped so this package's public surface is unchanged, the same way
6
+ // `@ultimat3/action` and `@ultimat3/query` re-export core's client-flight names.
7
+ export { localeInvalid } from '@ultimat3/core';
3
8
  export {
4
9
  addBusinessDays,
5
10
  type BusinessCalendar,
@@ -57,7 +62,6 @@ export {
57
62
  dstNonexistent,
58
63
  durationInvalid,
59
64
  instantInvalid,
60
- localeInvalid,
61
65
  TIME_ERROR_CODES,
62
66
  TIME_ERROR_TITLES,
63
67
  TimeError,
package/src/zones.ts CHANGED
@@ -4,10 +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 } from '@ultimat3/core';
7
+ import { assertLocale, cachedFormatter } from '@ultimat3/core';
8
8
  import { timezoneInvalid } from './errors';
9
9
  import type { Instant } from './instant';
10
- import { assertLocale } from './locale';
11
10
  import { canonicalTimeZone } from './zone-canonical';
12
11
 
13
12
  /** An IANA identifier: `Europe/Berlin`, `Asia/Kathmandu`, `UTC`. Never `CET`, never `+01:00`. */
package/src/locale.ts DELETED
@@ -1,24 +0,0 @@
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
- }