@ultimat3/time 3.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
@@ -67,6 +67,52 @@
67
67
  pins the process to UTC, so that bug is invisible to every in-process test — `plain-date.test.ts`
68
68
  spawns a subprocess with `TZ=America/Los_Angeles` for exactly one assertion, and that is the only
69
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.
70
116
  - Never add `86_400_000` to cross a day boundary — use `addDaysInZone` / `fromZoned`.
71
117
  - Never take the clock from `Date.now()`; accept a `Clock` (`now(clock)`).
72
118
  - Cron and schedules iterate the **local wall clock**, then convert once with `fromZoned`.
package/README.md CHANGED
@@ -28,6 +28,11 @@ canonicalization as a distinct string, so the key bounds nothing on its own. Bot
28
28
  `canonicalLocale` and `cachedFormatter` — are `@ultimat3/core`'s as of 2.0.0, so `@ultimat3/money`
29
29
  reads the same bound rather than a copy of it.
30
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.
35
+
31
36
  Every value this package hands back is its own object: `instant(date)` copies rather than branding
32
37
  the caller's `Date`, and `epoch()` is a function — the `EPOCH` constant it replaces was one shared
33
38
  mutable `Date` that a single `setUTCFullYear` corrupted for the whole process.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/time",
3
- "version": "3.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": "3.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
 
@@ -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) {
@@ -72,7 +77,7 @@ export function parseCron(expression: string): CronExpression {
72
77
  // 0 and 7 are both Sunday in cron; ISO calls Sunday 7.
73
78
  const daysOfWeek = [...new Set(rawDow.map((day) => (day === 0 ? 7 : day)))].sort((a, b) => a - b);
74
79
 
75
- return {
80
+ const parsed: CronExpression = {
76
81
  source: expanded,
77
82
  seconds,
78
83
  minutes,
@@ -83,6 +88,52 @@ export function parseCron(expression: string): CronExpression {
83
88
  dayOfMonthRestricted: isRestricted(domField ?? '*'),
84
89
  dayOfWeekRestricted: isRestricted(dowField ?? '*'),
85
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
+ );
86
137
  }
87
138
 
88
139
  export function isValidCron(expression: string): boolean {
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
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
8
8
  import { differenceMs, type Instant } from './instant';
9
+ import { isoDateInZone } from './zoned';
9
10
  import { assertTimeZone, type TimeZone } from './zones';
10
11
 
11
12
  export type DateTimeStyle = 'short' | 'medium' | 'long' | 'full';
@@ -86,15 +87,17 @@ export function formatWithOffset(at: Instant, options: FormatDateTimeOptions): s
86
87
  return offset === '' ? text : `${text} (${offset})`;
87
88
  }
88
89
 
89
- /** 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
+ */
90
99
  export function formatIsoDate(at: Instant, zone: TimeZone): string {
91
- const parts = formatterFor('en-CA', {
92
- timeZone: assertTimeZone(zone),
93
- year: 'numeric',
94
- month: '2-digit',
95
- day: '2-digit',
96
- }).format(at);
97
- return parts.replace(/\//g, '-');
100
+ return isoDateInZone(at, assertTimeZone(zone));
98
101
  }
99
102
 
100
103
  export interface FormatRelativeOptions extends Omit<FormatContext, 'zone'> {
@@ -155,12 +158,18 @@ const ORDINAL_SUFFIX: Record<Intl.LDMLPluralRule, string> = {
155
158
  };
156
159
 
157
160
  /**
158
- * `Intl` renders `November 5, 2011`, never `5th of November`. When a design asks for the
159
- * ordinal, build it from `Intl.PluralRules` with `type: 'ordinal'` — English-only by
160
- * 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.**
161
170
  */
162
- export function ordinal(value: number, locale = 'en'): string {
163
- 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);
164
173
  return `${value}${ORDINAL_SUFFIX[category]}`;
165
174
  }
166
175
 
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
  }