@ultimat3/time
Advanced tools
+18
-3
@@ -15,3 +15,2 @@ # @ultimat3/time — agent notes | ||
| | `format.ts` | `Intl` rendering. Every function takes `locale` **and** `zone`. | | ||
| | `locale.ts` | `assertLocale` — the ONE screen a caller-supplied BCP 47 tag passes before `Intl` | | ||
| | `duration.ts` | `'2h30m'` ⇄ ms | | ||
@@ -56,3 +55,5 @@ | `cron.ts` | barrel over the three cron modules — the only one `index.ts` re-exports | | ||
| which is true of the cache and was not an argument for letting the tag through. **Breaking at | ||
| 9.x.** `assertLocale` (`locale.ts`) is the one screen and it VALIDATES AND CANONICALIZES in one | ||
| 9.x.** `assertLocale` (**`@ultimat3/core`'s**, since 16.x — `locale.ts` is gone, because | ||
| `@ultimat3/money` needs the identical screen and tier 1 may not import sideways) is the one | ||
| screen and it VALIDATES AND CANONICALIZES in one | ||
| step — `Intl.getCanonicalLocales` runs the same structural check `supportedLocalesOf` throws on | ||
@@ -64,3 +65,4 @@ and hands back the spelling the cache keys on, so there is no second question to ask. Well-formed | ||
| for a locale) and the cache must be bounded (`cachedFormatter`). **`cachedFormatter`, | ||
| `MAX_CACHED_FORMATTERS` and `canonicalLocale` are `@ultimat3/core`'s as of 2.0.0**, not this | ||
| `MAX_CACHED_FORMATTERS`, `canonicalLocale` and `assertLocale` are `@ultimat3/core`'s** — the | ||
| first three as of 2.0.0 and the screen as of 16.x, with `X_LOCALE_INVALID` moving with it — not this | ||
| package's: `@ultimat3/money` hit the identical unbounded-`Map`-on-a-header bug and tier 1 may not | ||
@@ -147,2 +149,15 @@ import sideways, so the mechanism moved down a tier rather than being copied. An unbounded | ||
| - `m` is minutes, `ms` is milliseconds. A bare number is not a duration. | ||
| - **`toMs`'s NUMBER arm is screened, `As of 2026-08-26`** — `finiteOption('toMs', 'duration', …)`. | ||
| The string arm has always been total, because `parseDuration` refuses everything it cannot read; | ||
| the number arm passed straight through, so `toMs(Number(process.env.TTL_MS))` on an unset | ||
| variable answered `NaN` and every `wakeAt > now` built from it read false forever — a sleep that | ||
| never ends and a timeout that never fires, with no error anywhere. `??` does not guard it: `NaN` | ||
| is not nullish. `@ultimat3/notify`'s `toDurationMs` had screened its own copy of the body and | ||
| this one had not, so one duration vocabulary gave two answers to one input (#372); | ||
| `packages/notify/src/plan-bounds.test.ts` calls BOTH on the same inputs and is what keeps them | ||
| from drifting apart again. | ||
| **`finiteOption`, never `finiteCount`**: a duration here is legitimately NEGATIVE — | ||
| `parseDuration` accepts a leading `-`, and `toSeconds(-3000)` is a tested `-3` — and legitimately | ||
| fractional. A caller that needs whole non-negative milliseconds narrows on top; narrowing here | ||
| breaks the signed-duration contract `toSeconds` is built on. | ||
| - Tests must cover a spring-forward gap, a fall-back overlap and a non-hour offset zone. | ||
@@ -149,0 +164,0 @@ |
+2
-2
| { | ||
| "name": "@ultimat3/time", | ||
| "version": "16.0.0", | ||
| "version": "17.0.0", | ||
| "description": "UTC instants, DST-correct zone math, cron, durations and Intl formatting with an explicit timezone", | ||
@@ -37,4 +37,4 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "16.0.0" | ||
| "@ultimat3/core": "17.0.0" | ||
| } | ||
| } |
+1
-1
@@ -157,3 +157,3 @@ # 🕒 @ultimat3/time | ||
| | `X_INSTANT_INVALID` | unparseable timestamp | | ||
| | `X_LOCALE_INVALID` | a tag `Intl` cannot parse (`en_US`, `''`) reached `describeCron` | | ||
| | `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 | | ||
| | `X_CRON_NOT_DESCRIBABLE` | a valid 6-field cron whose seconds field `CronPhrases` has no words for | | ||
@@ -160,0 +160,0 @@ | `X_SCHEDULE_INVALID` | a wall-clock field out of range: `slot.hour`, `slot.minute`, `slot.second`, `slot.weekday` | |
@@ -7,6 +7,5 @@ /** | ||
| import { cachedFormatter } from '@ultimat3/core'; | ||
| import { assertLocale, cachedFormatter } from '@ultimat3/core'; | ||
| import { type CronExpression, parseCronOnce } from './cron-parse'; | ||
| import { cronNotDescribable } from './errors'; | ||
| import { assertLocale } from './locale'; | ||
@@ -13,0 +12,0 @@ export interface CronPhrases { |
+19
-3
@@ -6,4 +6,4 @@ /** | ||
| import { assertLocale, finiteOption } from '@ultimat3/core'; | ||
| import { durationInvalid, scheduleInvalid } from './errors'; | ||
| import { assertLocale } from './locale'; | ||
@@ -65,5 +65,21 @@ export const MS = 1; | ||
| /** Parse-or-passthrough for APIs that accept either form. */ | ||
| /** | ||
| * Parse-or-passthrough for APIs that accept either form. | ||
| * | ||
| * The STRING arm has always been total — `parseDuration` refuses everything it cannot read. The | ||
| * NUMBER arm was the hole: it passed straight through, so `toMs(Number(process.env.TTL_MS))` on an | ||
| * unset variable answered `NaN`, and every `wakeAt > now` built from it reads false forever — a | ||
| * sleep that never ends, a timeout that never fires, no error anywhere. `??` does not guard it, | ||
| * because `NaN` is not nullish. `@ultimat3/notify` screened its own copy of this body and this one | ||
| * did not, so one duration vocabulary gave two answers to one input. | ||
| * | ||
| * `finiteOption`, not `finiteCount`: a duration here is legitimately NEGATIVE — `parseDuration` | ||
| * accepts a leading `-` and `toSeconds(-3000)` is a tested `-3` — and legitimately fractional. | ||
| * A caller that needs whole non-negative milliseconds narrows on top of this, which is what | ||
| * `@ultimat3/notify`'s `toDurationMs` does; narrowing here would break both. | ||
| */ | ||
| export function toMs(duration: string | number): number { | ||
| return typeof duration === 'number' ? duration : parseDuration(duration); | ||
| return typeof duration === 'number' | ||
| ? finiteOption('toMs', 'duration', duration) | ||
| : parseDuration(duration); | ||
| } | ||
@@ -70,0 +86,0 @@ |
+4
-15
| /** | ||
| * The X_* error codes owned by @ultimat3/time. | ||
| * DST ambiguity is a real state of the world, so it gets a code instead of a guess. | ||
| * | ||
| * `X_LOCALE_INVALID` is NOT here: it moved to `@ultimat3/core` beside `assertLocale`, because | ||
| * `@ultimat3/money` needs the same screen and tier 1 may not import sideways. A code has exactly | ||
| * one declaration — a second `registerErrorCodes` claim is `X_ERROR_CODE_DUPLICATE`. | ||
| */ | ||
@@ -16,3 +20,2 @@ | ||
| 'X_SCHEDULE_INVALID', | ||
| 'X_LOCALE_INVALID', | ||
| 'X_CRON_NOT_DESCRIBABLE', | ||
@@ -31,3 +34,2 @@ ] as const; | ||
| X_SCHEDULE_INVALID: 'a wall-clock field is out of range', | ||
| X_LOCALE_INVALID: 'not a well-formed BCP 47 tag', | ||
| X_CRON_NOT_DESCRIBABLE: 'a valid cron expression describeCron has no vocabulary for', | ||
@@ -128,15 +130,2 @@ }; | ||
| /** | ||
| * A tag `Intl` cannot parse. Distinct from i18n's `X_LOCALE_UNSUPPORTED`, which is a | ||
| * well-formed tag outside the app's supported set — this one is not a tag at all, and a raw | ||
| * `RangeError` from a formatter says nothing about which caller supplied it. | ||
| */ | ||
| export function localeInvalid(locale: string): TimeError { | ||
| return new TimeError({ | ||
| code: 'X_LOCALE_INVALID', | ||
| cause: `"${locale}" is not a well-formed BCP 47 language tag`, | ||
| 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", | ||
| }); | ||
| } | ||
| export function instantInvalid(input: string): TimeError { | ||
@@ -143,0 +132,0 @@ return new TimeError({ |
+1
-2
@@ -7,5 +7,4 @@ /** | ||
| import { cachedFormatter } from '@ultimat3/core'; | ||
| import { assertLocale, cachedFormatter } from '@ultimat3/core'; | ||
| import { differenceMs, type Instant } from './instant'; | ||
| import { assertLocale } from './locale'; | ||
| import { isoDateInZone } from './zoned'; | ||
@@ -12,0 +11,0 @@ import { assertTimeZone, type TimeZone } from './zones'; |
+5
-1
| /** Public surface of @ultimat3/time. Explicit exports only. */ | ||
| // `localeInvalid` and the `X_LOCALE_INVALID` it carries are `@ultimat3/core`'s since 16.x — | ||
| // `@ultimat3/money` needs the same screen and `money -> time` is a sideways import. Re-exported | ||
| // rather than dropped so this package's public surface is unchanged, the same way | ||
| // `@ultimat3/action` and `@ultimat3/query` re-export core's client-flight names. | ||
| export { localeInvalid } from '@ultimat3/core'; | ||
| export { | ||
@@ -60,3 +65,2 @@ addBusinessDays, | ||
| instantInvalid, | ||
| localeInvalid, | ||
| TIME_ERROR_CODES, | ||
@@ -63,0 +67,0 @@ TIME_ERROR_TITLES, |
+1
-2
@@ -7,6 +7,5 @@ /** | ||
| import { cachedFormatter } from '@ultimat3/core'; | ||
| import { assertLocale, cachedFormatter } from '@ultimat3/core'; | ||
| import { timezoneInvalid } from './errors'; | ||
| import type { Instant } from './instant'; | ||
| import { assertLocale } from './locale'; | ||
| import { canonicalTimeZone } from './zone-canonical'; | ||
@@ -13,0 +12,0 @@ |
| // Single responsibility: the one place a caller-supplied BCP 47 tag is screened before it reaches | ||
| // an `Intl` constructor. One question, one answer (axiom 1) — `cron-describe.ts` refused a | ||
| // malformed tag from the start while seven sibling formatters handed the raw string to `Intl` and | ||
| // let a bare, uncoded `RangeError` escape several frames from the header it came out of. | ||
| import { canonicalLocale } from '@ultimat3/core'; | ||
| import { localeInvalid } from './errors'; | ||
| /** | ||
| * The canonical spelling of a well-formed tag, or `X_LOCALE_INVALID`. | ||
| * | ||
| * Validating and keying are one step: `Intl.getCanonicalLocales` runs exactly the structural check | ||
| * `Intl.DateTimeFormat.supportedLocalesOf` throws on — which is what `localeInvalid`'s `fix:` tells | ||
| * the caller to run — and unlike it hands back the spelling every formatter cache keys on, so | ||
| * `EN-us` and `en-US` cannot mint two entries for one locale. | ||
| * | ||
| * Well-formed but unknown to this runtime's ICU (`zz`) is NOT refused: `Intl` falls back for those, | ||
| * and a user carrying a locale the runtime has no data for must still get a rendered page. | ||
| */ | ||
| export function assertLocale(locale: string): string { | ||
| const tag = canonicalLocale(locale); | ||
| if (tag === undefined) throw localeInvalid(locale); | ||
| return tag; | ||
| } |
118108
1.05%20
-4.76%2208
-0.67%+ Added
- Removed
Updated