Skip to content

Commit a6866da

Browse files
fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) (#20746)
Fixes #20599 Clause-②: yes ## What changes `Date.UTC(year, …)` and `new Date(year, …)` read a year from 0 to 99 as 1900 + year (ECMA-262 `MakeFullYear`). Core built UTC instants from parts that way, so every day of 0001..0099, inside the supported range 0001..9999, landed in the 1900s with no error. - **`@objectstack/core` gains one root export, `wallClockToUtcMs(parts: WallClockParts): number`.** It is `Date.UTC` without the remap, built as `new Date(0)`, then `setUTCFullYear`, then `setUTCHours`. `month` is 1-12. Every component rolls over past its end the way `Date.UTC` rolls it, and a `NaN` component gives `NaN`. It sits beside `zonedWallClockToUtcMs` in `utils/datetime.ts`, and the root barrel's `export *` carries it, so `packages/core/src/index.ts` is untouched. - **Every site the census below confirms now builds through it.** There is no per-site copy: - core: `zonedWallClockToUtcMs` (the wall clock and the zone-offset read), and through it `zonedDateStartToUtcMs`; `isoWeekLabelFromCalendarDay`; `bucketKeyToCalendarRange`; and `filter-tokens` (`proxyDay`, `startOfPeriod`, `daysInMonth`, `addMonthsClamped`); - `service-analytics`: `preview-evaluator.ts` `bucketDate`'s week key, and `dataset-executor.ts` `isoWeekKeyOfUtcMs`. The census found the second one; the card did not list it; - `trigger-schedule`: `time-relative-trigger.ts` `startOfUtcDay` / `endOfUtcDay`. - **The offset read also takes the zone's era.** `Intl`'s `year` part is an ERA year, so 1 BC reads `1`. A wall clock early on 0001-01-01 in a zone west of UTC probes the offset in year 0. Without the era, that probe reads a year late and the answer is garbage. `America/New_York` `0001-01-01 00:00` is pinned: it gives `0001-01-01T04:56:02.000Z`. - **`filter-tokens` spells its day with core's `temporalStorageForm` `date` rule.** Before, a hand-rolled `ymd()` wrote the year unpadded. That spelling was unreachable for 0001..0099 while `Date.UTC` threw those years into the 1900s. This change makes it reachable: `{1976_years_ago}` would have become `50-09-30`, which names no day. So the fix pads it, and a macro step into 0100..0999 is padded too (`{720000_days_ago}` gives `0055-06-15`). This is a mandatory fix under the "a shipped defect this change touches" rule, and it is not the bucket-key padding family (see "Not in this PR"). - `packages/rest/src/import-coerce.ts` is **unchanged** (H3). Its door is fixed through core. ## Census (before any fix, at `origin/main` `4dfff176b9`) `git grep -n "Date\.UTC("` and `git grep -nE "new Date\(\s*[^)\"'\x60]*,"` over non-test tracked files, all packages and scripts. That gave 49 `Date.UTC(` lines and 6 multi-argument `new Date(` lines. Every multi-argument `new Date(` hit is a comment, or a single-argument call caught by the pattern. | site (line at `4dfff176b9`) | can a year below 100 reach it? | disposition | |---|---|---| | core `datetime.ts:143` `zonedWallClockToUtcMs` wall clock | **yes**: the `POST /import` `datetime` cell, measured below | helper | | core `datetime.ts:174` offset read | **yes**: once the wall clock keeps year 50, the probe instant is in year 50 (and in year 0 for 0001 west of UTC) | helper + era | | core `datetime.ts:314` / `:317` ISO-week label | **yes**: `bucketDateKey(week)` of a stored year-50 instant (in-memory aggregation, analytics) | helper | | core `datetime.ts:374`–`:414` `bucketKeyToCalendarRange` | **yes**: a padded key such as `0050` from a SQL driver's bucket expression, drilled | helper | | core `filter-tokens.ts:198` `proxyDay`, `:215`–`:219` `startOfPeriod` | no: derived from `now`, the clock, at every caller (`filterTokenContextFrom(ctx, new Date())` or unset) | helper (the family closes) | | core `filter-tokens.ts:225` `daysInMonth` | reachable, benign: a month's length is the same in Y and 1900 + Y for Y in 1..99 | helper | | core `filter-tokens.ts:243` `addMonthsClamped` | **yes**: `{N_months_ago}` / `{N_years_ago}`; the grammar's N is unbounded (`DATE_MACRO_PARAM_RE`) | helper | | `service-analytics` `preview-evaluator.ts:367` week key | **yes**: a preview row in year 50 | helper | | `service-analytics` `dataset-executor.ts:841` `isoWeekKeyOfUtcMs` | **yes**: `compareTo` alignment on a week ordinal in year 50 | helper | | `trigger-schedule` `time-relative-trigger.ts:118` / `:123` | **yes**: `offsetDays` / `withinDays` are unbounded ints in spec, so an offset reaches 1..99 | helper | | `formula` `stdlib.ts:59` `calendarDayUtc` | no: reads `now()` only (the pinned evaluation clock) | unchanged; `formula` depends on `spec` alone and cannot import core | | `formula` `stdlib.ts:102` `addMonthsUtc` | reachable, benign: day count only, same as `daysInMonth` | unchanged | | `examples/app-todo` `task.functions.ts:47` | benign, the same day-count shape | unchanged | | `service-messaging` `preference-resolver.ts:359` | no: `nowMs` clock | unchanged | | `service-sms` `sms-daily-quota.ts:141` | no: `now` clock | unchanged | | `driver-mongodb` `mongodb-pipeline-evaluator.testkit.ts:92` / `:147` / `:151` | test model, imported only by tests | unchanged (Acceptance notes) | | spec `calendar-day.ts:170`, rest `import-coerce.ts:453`, `driver-sql` `sql-driver.ts:6892` / `:6893` / `:6989` / `:15303`, `driver-turso` `:71` | comments | none | | spec `filter-number-comparand-declared-type.ts:909` | the constant 2026 | none | | `scripts/**` (`check-osv-exemptions`, `pm/*`, `qa/qa-rollup`, `sync-release-index-currency`) | tooling; fixed 2026 constants or 2020s dates read from files | none | ## Reach, measured at the door The in-process route harness drives `POST /api/v1/data/:object/import` and reads back through `POST /api/v1/data/:object/query` over ObjectQL and SqlDriver. The base reading restores `packages/core/src/utils/{datetime,filter-tokens}.ts` to `4dfff176b9` and rebuilds core; core is the only package on this door that the diff touches. The PostgreSQL 16.13 server ran at `timezone=Asia/Shanghai`. | | SQLite, base | PostgreSQL 16, base | SQLite and PostgreSQL 16, this branch | |---|---|---|---| | `0050-01-01 10:00`, no zone | `1950-01-01T10:00:00.000Z`, ok 2 / errors 0 | same | `0050-01-01T10:00:00.000Z` | | `0050-01-01 10:00`, Asia/Shanghai | `1950-01-01T02:00:00.000Z` | same | `0050-01-01T01:54:17.000Z` (LMT +08:05:43) | | `2026-07-15 10:00` control | `2026-07-15T10:00:00.000Z` / `…02:00:00.000Z` | same | same as base | | export, then import, `0001` / `0050` / `0100` at `…-01-01T10:00Z`, export as written | refused, ok 0 / errors 3 (`1-01-01 …`, `50-01-01 …`, `100-01-01 …` unpadded) | same | same: the export's padding is PR #20688's | | the same, with the year padded as PR #20688's export writes it | `1901-01-01T10:00Z`, `1950-01-01T10:00Z`, `0100` exact; Asia/Shanghai: `1950-01-01T10:05:43Z` | same | all three exact, both zones | ## Mechanism hypotheses (zone 2), as measured - **H1 held**, and one site more: the root is core's `datetime.ts`, at the listed lines. The offset read also needed the zone's era for a year-0 probe. - **H2 held**: - a new core root export, used by core, `service-analytics` and `trigger-schedule`; - `rest` needs no import (H3); - `formula` cannot import core, and needs no change: its two sites are clock-only or benign; - no existing export fits: `zonedWallClockToUtcMs` with no zone equals the helper, but only through its documented fallback. - **H3 held.** PR #20601's bare-day `datetime` path goes through `Date.parse`. A cell with a time goes through `zonedWallClockToUtcMs`, which is now correct, so `import-coerce.ts` is untouched. - **H4 partly falsified.** The `Date.UTC` half is gone: `bucketDateKey('0050-01-01T10:00Z', week)` is week 52 of 0049, not of 1949. `bucketKeyToCalendarRange` spans padded keys (`0050`, `0050-Q4`, `0050-12`, `0050-01-01`) in their own years. The round trip from `bucketDateKey` to `bucketKeyToCalendarRange` still does **not** hold below year 1000, at any granularity: - `bucketDateKey` spells those years unpadded (`50`, `50-Q1`, `50-01`, `50-01-01`, `49-W52`), and the range function reads only `\d{4}`; - the week arm validates against the unpadded label, so even a padded SQL key `0050-W01` answers `null`; - that is the unpadded-key family, which the dispatch excluded (out-of-scope finding 1). - **H5 held.** The rollover is load-bearing at Q4's and December's end (month 13), a day key's end (day 32) and `daysInMonth` (day 0). The helper rolls identically. Nine rollover cases are pinned in 0001..0099, plus four 2026 cases equal to `Date.UTC`. ## Pins Each file runs its zone-free sites on a UTC host and on an Asia/Shanghai host (`process.env.TZ`, asserted to have taken). - `packages/core/src/utils/datetime-year-below-100.test.ts` (160 cases): the helper, rollover and `NaN`; `zonedWallClockToUtcMs` and `zonedDateStartToUtcMs` with no zone, UTC, Asia/Shanghai and America/New_York; `bucketDateKey` week in UTC and Asia/Shanghai; `bucketKeyToCalendarRange` year, quarter, month and day, plus the 2026 week control. - `packages/core/src/utils/filter-tokens-year-below-100.test.ts` (26): year and month macros into 0001, 0050 and 0099 in UTC and Asia/Shanghai, the February-29 clamp in years 48, 50 and 100, and day steps padded. - `packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts` (42): the preview `bucketDate` week, and the `compareTo` ordinals from `bucketOrdinalOfDay` and `bucketKeyAtOrdinal`. - `packages/triggers/trigger-schedule/src/time-relative-window-year-below-100.test.ts` (20): `offsetDays` and `withinDays` windows, and the claim scope. - `packages/rest/src/import-datetime-year-below-100.test.ts` (74). This is the `domain:cli` seat's round-trip pin, in their `export-date-year-pad.test.ts` harness pattern: - `parseDateCell` on two hosts; - `POST /import` of a CSV with no zone, UTC and Asia/Shanghai; - rows created, sent through `GET /export` (csv and json) and re-imported into a fresh stack, under no zone, Asia/Shanghai and America/New_York, for 0001, 0050, 0099, 0100 and 2026. - The round trip pads an exported year below 1000 to four digits before re-importing, as PR #20688's export will write it. That half is PR #20688's (`Blocked-by: #20599`); once it lands, the step changes nothing, and PR #20688 can drop its own `dt: undefined` exclusion for 0001 and 0050. Every expected instant is spelled as an ISO string, never computed by the code under test. ## Reverse verification (committed first, rebuilt, and checked in `dist/`) - **Mutate.** `node scripts/ablation-replace.mjs` replaced the helper's body with `Date.UTC(parts.year, …)`: anchor 1 → 0, blob `fda5c68ba9bb` → `9d0def6aaa50`. Then `pnpm --filter @objectstack/core build`, and `ablation-dist-preflight.mjs @objectstack/core 'Date.UTC(parts.year'` said the marker is present in 2 built files. Result: core **120 failed / 66 passed**, service-analytics **28 / 14**, trigger-schedule **12 / 8**, rest **38 / 36**. For example, `expected '1950-01-01T00:00:00.000Z' to be '0050-01-01T00:00:00.000Z'`, preview week `expected '1949-12-26' to be '0049-12-27'`, and window `gte '1950-09-30T00:00:00.000Z'`. Every 0100, 2026, `NaN` and padding control stayed green. The direction was red, as predicted. - **Restore.** The blob equals HEAD `fda5c68ba9bb`, and `git diff HEAD` is empty. After a rebuild, the preflight with `--absent 'Date.UTC(parts.year'` finds the marker in none of 14 files, and the tree is clean. Result: **186 / 42 / 20 / 74 passed**. - **Base leg.** With core's two files at `4dfff176b9` (blob match: yes), rest's pin file gave **38 failed / 36 passed**. Exactly the 0001, 0050 and 0099 rows failed; `imports every row` stayed green, which is the silent `ok`. ## Tests and gates (after the final commit, `da39ddacc0`) - `pnpm --filter PKG test`: - core 60 files / 1728 tests; - service-analytics 140 / 3266; - trigger-schedule 8 / 170; - rest 229 / 4461 passed and 55 skipped; - objectql 337 / 6687 (a consumer of `bucketDateKey` and the filter tokens). - The non-SQL temporal suite under `TZ=America/New_York`, asserted to have taken, all green: core, formula 42 / 1240, driver-memory 65 / 1470, driver-mongodb 29 / 661 (172 skipped), service-analytics. - `pnpm --filter PKG typecheck` is green for core, service-analytics, trigger-schedule and rest. Each program's `--listFiles` includes the new test files. - `node scripts/pm/dispatch-gates.mjs --commands`: 64 commands, all exit 0. `check:dual-build-cjs-loads` and `check:type-check-debt` first answered `PREREQUISITE NOT MET` (exit 3), and answered 0 after `turbo run build --filter='./packages/*' --filter='./packages/*/*'`. `--ran`: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN. - eslint, narrowed: - population: the 10 changed `.ts` files, all matched by `eslint.config.mjs`'s `files` globs; - count: `--format json` read 10 files, 0 errors and 0 warnings; - invariance: `--print-config` shows no `parserOptions.project` or `projectService`. That is, no type-aware linting, and the only cross-file inputs are two baselines this diff does not touch, so no untouched file's verdict can move. - **NOT MEASURED: the live `driver-sql` leg of Temporal Conformance.** This container has no MySQL server, and no `driver-sql` source imports a changed helper. CI runs it. ## Not in this PR - spec `calendar-day.ts`, which PR #20591 already corrected. - The unpadded year in bucket keys and the export (#20534 / #20602), and MySQL's read-back (#20280). #20602 and #20280 are not addressed here. - PR #20688 stays drafted by the `domain:cli` seat. This PR removes the `Blocked-by` cause. ## Acceptance notes - **Out-of-scope finding 1, reported to the seat and not filed from here.** `bucketDateKey`, `isoWeekLabelFromCalendarDay` and service-analytics `bucketKeyAtOrdinal` spell a year below 1000 unpadded. SQL drivers' bucket expressions pad it (`strftime('%Y')`), so the in-memory and pushed-down keys differ for those years, and `bucketKeyToCalendarRange`'s week arm answers `null` even for a padded key. This belongs to the unpadded-year family of #20602. - `formula` keeps a private calendar-day copy (`stdlib.ts:59`), reached only through `now()`, and a day-count use of `Date.UTC` (`:102`). Both read right for 1..99, so they are unchanged. `examples/app-todo` has the same day-count shape. - `driver-mongodb`'s `mongodb-pipeline-evaluator.testkit.ts` reads an ISO string through `Date.UTC` and would model a year-50 instant in 1950. It is a test model, not product; noted with no carrier. - The forward direction `calendarPartsInTz` reads `Intl`'s era year too. It matters only for an instant whose local day is before 0001-01-01, which is outside the supported range. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c8f113 commit a6866da

11 files changed

Lines changed: 864 additions & 44 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/service-analytics": patch
4+
"@objectstack/trigger-schedule": patch
5+
---
6+
7+
fix(core): a date or time in the years 0001..0099 is read as written, not as 1900..1999, wherever a UTC instant is built from year / month / day / time parts
8+
9+
`Date.UTC(year, …)` and `new Date(year, …)` read a year from 0 to 99 as 1900 + year. Core built its instants from parts that way, so every day of the years 0001..0099 (inside the supported range 0001..9999) landed in the 1900s at the sites below, with no error.
10+
11+
- `@objectstack/core`: **new export** `wallClockToUtcMs(parts)`, the epoch milliseconds of a `WallClockParts` read as UTC. It is `Date.UTC` without the two-digit-year remap: `month` is 1-12, omitted time components are 0, and every component rolls over past its end as `Date.UTC` rolls it (`month: 13` is next January, `day: 0` the previous month's last day, `hour: 24` the next midnight). A `NaN` component gives `NaN`. Every site below now builds through it:
12+
- `zonedWallClockToUtcMs` and `zonedDateStartToUtcMs`, the wall clock and the zone-offset read. The offset read also takes the zone's era, so a wall clock early on 0001-01-01 in a zone west of UTC, whose offset probe lands in year 0, reads right.
13+
- `bucketKeyToCalendarRange` (`0050` spans 0050-01-01..0051-01-01, not 1950..1951; `0050-01-01` as a `day` key is no longer `null`) and `bucketDateKey`'s ISO week (0050-01-01 is in week 52 of 0049, not of 1949).
14+
- The date macros: `{1976_years_ago}` resolves to `0050-09-30`, not `1950-09-30`. A macro that lands in 0001..0999 is now spelled with a four-digit year, as the `date` storage form spells it (`0055-06-15`, not `55-06-15`, which names no day).
15+
- `@objectstack/service-analytics`: the preview evaluator's `week` key and the `compareTo` bucket alignment build their days through `wallClockToUtcMs`.
16+
- `@objectstack/trigger-schedule`: a time-relative window's day bounds build through `wallClockToUtcMs`.
17+
18+
What an author sees: `POST /api/v1/data/:object/import` stores the `datetime` cell `0050-01-01 10:00` as `0050-01-01T10:00:00.000Z`, and in `Asia/Shanghai` as `0050-01-01T01:54:17.000Z` (the zone's local mean time for that year). Before, it stored `1950-01-01T10:00:00.000Z` and `1950-01-01T02:00:00.000Z` and reported the row `ok`. Measured through the import route and read back through `POST /api/v1/data/:object/query` on SQLite and PostgreSQL 16; the `2026-07-15 10:00` control is stored the same before and after. Every year from 0100 on builds exactly as before.
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#20599] Wherever core builds a UTC instant from year / month / day / time
4+
// parts, a year from 0001 to 0099 is read as itself. `Date.UTC(year, …)` and
5+
// `new Date(year, …)` read a year from 0 to 99 as 1900 + year (ECMA-262
6+
// `MakeFullYear`); `wallClockToUtcMs` does not, and every site in this file
7+
// builds through it.
8+
//
9+
// Pins: 0001, 0050 and 0099 (the remapped years), 0100 (the first year
10+
// `Date.UTC` reads as written) and a 2026 control, in UTC and in Asia/Shanghai
11+
// wherever the site takes a zone. Every expected instant is spelled as an ISO
12+
// string, never computed by the code under test. Before 1901 the tz database
13+
// gives Asia/Shanghai its local mean time, +08:05:43, and America/New_York
14+
// −04:56:02: that is the offset a wall clock in those years is read at.
15+
16+
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
17+
import {
18+
wallClockToUtcMs,
19+
zonedWallClockToUtcMs,
20+
zonedDateStartToUtcMs,
21+
bucketDateKey,
22+
bucketKeyToCalendarRange,
23+
} from './datetime.js';
24+
25+
const isoOf = (ms: number) => new Date(ms).toISOString();
26+
27+
const YEARS = ['0001', '0050', '0099', '0100', '2026'] as const;
28+
29+
// Every pin runs on a UTC host and on an Asia/Shanghai host: the sites read
30+
// UTC components only, and the answer must not move with the process zone.
31+
const HOSTS = ['UTC', 'Asia/Shanghai'] as const;
32+
const originalTz = process.env.TZ;
33+
afterAll(() => {
34+
if (originalTz === undefined) delete process.env.TZ;
35+
else process.env.TZ = originalTz;
36+
});
37+
38+
describe.each(HOSTS)('on a %s host', (host) => {
39+
beforeEach(() => {
40+
process.env.TZ = host;
41+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host);
42+
});
43+
44+
describe('[#20599] wallClockToUtcMs builds a UTC instant with the year as written', () => {
45+
it.each(YEARS)('%s-01-01 is midnight UTC of that year', (y) => {
46+
expect(isoOf(wallClockToUtcMs({ year: Number(y), month: 1, day: 1 }))).toBe(`${y}-01-01T00:00:00.000Z`);
47+
});
48+
49+
it.each(YEARS)('%s-06-15 10:20:30.456 keeps every time component', (y) => {
50+
const ms = wallClockToUtcMs({ year: Number(y), month: 6, day: 15, hour: 10, minute: 20, second: 30, millisecond: 456 });
51+
expect(isoOf(ms)).toBe(`${y}-06-15T10:20:30.456Z`);
52+
});
53+
54+
it('answers exactly what Date.UTC answers from year 100 on', () => {
55+
for (const [year, month, day, hour, minute, second, ms] of [
56+
[100, 1, 1, 0, 0, 0, 0],
57+
[999, 12, 31, 23, 59, 59, 999],
58+
[2026, 7, 15, 10, 0, 0, 0],
59+
[9999, 12, 31, 23, 59, 59, 999],
60+
]) {
61+
expect(wallClockToUtcMs({ year, month, day, hour, minute, second, millisecond: ms })).toBe(
62+
Date.UTC(year, month - 1, day, hour, minute, second, ms),
63+
);
64+
}
65+
});
66+
67+
// H5: the rollover is load-bearing — `bucketKeyToCalendarRange` ends Q4 and
68+
// December at month 13, and `filter-tokens` counts a month's days as day 0 of
69+
// the next. The helper rolls every component over as `Date.UTC` does.
70+
describe('rolls every component over past its end, as Date.UTC does', () => {
71+
it.each([
72+
['month 13 is January of the next year', { year: 50, month: 13, day: 1 }, '0051-01-01T00:00:00.000Z'],
73+
['month 0 is December of the previous year', { year: 50, month: 0, day: 1 }, '0049-12-01T00:00:00.000Z'],
74+
['day 0 is the last day of the previous month', { year: 50, month: 3, day: 0 }, '0050-02-28T00:00:00.000Z'],
75+
['day 0 of March is February 29 in a leap year', { year: 48, month: 3, day: 0 }, '0048-02-29T00:00:00.000Z'],
76+
['day 32 of December runs into the next year', { year: 50, month: 12, day: 32 }, '0051-01-01T00:00:00.000Z'],
77+
['February 29 of a common year is March 1', { year: 50, month: 2, day: 29 }, '0050-03-01T00:00:00.000Z'],
78+
['hour 24 is the next midnight', { year: 99, month: 12, day: 31, hour: 24 }, '0100-01-01T00:00:00.000Z'],
79+
['hour −1 is the previous evening', { year: 50, month: 1, day: 1, hour: -1 }, '0049-12-31T23:00:00.000Z'],
80+
['day 0 of January of year 1 is the last day of year 0', { year: 1, month: 1, day: 0 }, '0000-12-31T00:00:00.000Z'],
81+
])('%s', (_label, parts, expected) => {
82+
expect(isoOf(wallClockToUtcMs(parts))).toBe(expected);
83+
});
84+
85+
it('rolls the 2026 control exactly as Date.UTC does', () => {
86+
expect(wallClockToUtcMs({ year: 2026, month: 13, day: 1 })).toBe(Date.UTC(2026, 12, 1));
87+
expect(wallClockToUtcMs({ year: 2026, month: 3, day: 0 })).toBe(Date.UTC(2026, 2, 0));
88+
expect(wallClockToUtcMs({ year: 2026, month: 12, day: 32 })).toBe(Date.UTC(2026, 11, 32));
89+
expect(wallClockToUtcMs({ year: 2026, month: 1, day: 1, hour: 24 })).toBe(Date.UTC(2026, 0, 1, 24));
90+
});
91+
});
92+
93+
it('answers NaN for a NaN or non-finite component, as Date.UTC does', () => {
94+
expect(wallClockToUtcMs({ year: Number.NaN, month: 1, day: 1 })).toBeNaN();
95+
expect(wallClockToUtcMs({ year: 2026, month: Number.NaN, day: 1 })).toBeNaN();
96+
expect(wallClockToUtcMs({ year: 2026, month: 1, day: Number.NaN })).toBeNaN();
97+
expect(wallClockToUtcMs({ year: 2026, month: 1, day: 1, hour: Number.NaN })).toBeNaN();
98+
expect(wallClockToUtcMs({ year: Number.POSITIVE_INFINITY, month: 1, day: 1 })).toBeNaN();
99+
});
100+
});
101+
102+
describe('[#20599] zonedWallClockToUtcMs reads a year below 100 as written', () => {
103+
it.each(YEARS)('%s-01-01 10:00 with no zone, UTC or an unknown zone is 10:00 UTC that day', (y) => {
104+
for (const tz of [undefined, 'UTC', 'Not/AZone']) {
105+
expect(isoOf(zonedWallClockToUtcMs({ year: Number(y), month: 1, day: 1, hour: 10 }, tz))).toBe(
106+
`${y}-01-01T10:00:00.000Z`,
107+
);
108+
}
109+
});
110+
111+
it.each([
112+
['0001', '0001-01-01T01:54:17.000Z'],
113+
['0050', '0050-01-01T01:54:17.000Z'],
114+
['0099', '0099-01-01T01:54:17.000Z'],
115+
['0100', '0100-01-01T01:54:17.000Z'],
116+
['2026', '2026-01-01T02:00:00.000Z'],
117+
])('%s-01-01 10:00 in Asia/Shanghai is read at that year\'s offset', (y, expected) => {
118+
expect(isoOf(zonedWallClockToUtcMs({ year: Number(y), month: 1, day: 1, hour: 10 }, 'Asia/Shanghai'))).toBe(expected);
119+
});
120+
121+
it('keeps the last second of 0099 in 0099 in Asia/Shanghai', () => {
122+
const ms = zonedWallClockToUtcMs({ year: 99, month: 12, day: 31, hour: 23, minute: 59, second: 59 }, 'Asia/Shanghai');
123+
expect(isoOf(ms)).toBe('0099-12-31T15:54:16.000Z');
124+
});
125+
126+
// The offset read probes the zone at the wall clock taken as UTC. Early on
127+
// 0001-01-01 in a zone west of UTC that probe is still in year 0, whose
128+
// `Intl` year part is the era year `1` (1 BC): read without its era, the
129+
// probe would be a year off and so would the answer.
130+
it('reads 0001-01-01 in America/New_York, where the offset probe lands in year 0', () => {
131+
expect(isoOf(zonedWallClockToUtcMs({ year: 1, month: 1, day: 1 }, 'America/New_York'))).toBe(
132+
'0001-01-01T04:56:02.000Z',
133+
);
134+
expect(isoOf(zonedWallClockToUtcMs({ year: 1, month: 1, day: 1, hour: 3 }, 'America/New_York'))).toBe(
135+
'0001-01-01T07:56:02.000Z',
136+
);
137+
expect(isoOf(zonedWallClockToUtcMs({ year: 2026, month: 1, day: 1 }, 'America/New_York'))).toBe(
138+
'2026-01-01T05:00:00.000Z',
139+
);
140+
});
141+
});
142+
143+
describe('[#20599] zonedDateStartToUtcMs', () => {
144+
it.each(YEARS)('%s-01-01 begins at midnight UTC with no zone and in UTC', (y) => {
145+
expect(isoOf(zonedDateStartToUtcMs(`${y}-01-01`))).toBe(`${y}-01-01T00:00:00.000Z`);
146+
expect(isoOf(zonedDateStartToUtcMs(`${y}-01-01`, 'UTC'))).toBe(`${y}-01-01T00:00:00.000Z`);
147+
});
148+
149+
it.each([
150+
['0001', '0000-12-31T15:54:17.000Z'],
151+
['0050', '0049-12-31T15:54:17.000Z'],
152+
['0099', '0098-12-31T15:54:17.000Z'],
153+
['0100', '0099-12-31T15:54:17.000Z'],
154+
['2026', '2025-12-31T16:00:00.000Z'],
155+
])('%s-01-01 begins at Asia/Shanghai midnight', (y, expected) => {
156+
expect(isoOf(zonedDateStartToUtcMs(`${y}-01-01`, 'Asia/Shanghai'))).toBe(expected);
157+
});
158+
});
159+
160+
describe('[#20599] bucketDateKey(week) puts a day in 0001..0099 in its own ISO week', () => {
161+
// A key's year below 1000 is spelled unpadded (`49-W52`): the unpadded-key
162+
// family, which this card leaves alone. So the key is read as numbers here,
163+
// whatever its padding, and asserted as the ISO week of the day.
164+
const weekOf = (key: string | null) => {
165+
const m = /^(\d+)-W(\d{2})$/.exec(String(key));
166+
return m ? { year: Number(m[1]), week: Number(m[2]) } : key;
167+
};
168+
169+
it.each([
170+
['0001-01-01T10:00:00.000Z', { year: 1, week: 1 }],
171+
['0050-01-01T10:00:00.000Z', { year: 49, week: 52 }],
172+
['0050-06-15T10:00:00.000Z', { year: 50, week: 24 }],
173+
['0099-12-31T10:00:00.000Z', { year: 99, week: 53 }],
174+
['0100-01-04T10:00:00.000Z', { year: 100, week: 1 }],
175+
['2026-06-15T10:00:00.000Z', { year: 2026, week: 25 }],
176+
])('%s in UTC', (instant, expected) => {
177+
expect(weekOf(bucketDateKey(instant, 'week'))).toEqual(expected);
178+
expect(weekOf(bucketDateKey(instant, 'week', 'UTC'))).toEqual(expected);
179+
});
180+
181+
it.each([
182+
// Sunday 0050-01-02 in UTC is Monday 0050-01-03 in Shanghai: week 1 of 0050.
183+
['0050-01-02T20:00:00.000Z', { year: 49, week: 52 }, { year: 50, week: 1 }],
184+
['0001-01-07T20:00:00.000Z', { year: 1, week: 1 }, { year: 1, week: 2 }],
185+
['0099-12-31T20:00:00.000Z', { year: 99, week: 53 }, { year: 99, week: 53 }],
186+
['2026-06-14T20:00:00.000Z', { year: 2026, week: 24 }, { year: 2026, week: 25 }],
187+
])('%s in UTC and in Asia/Shanghai', (instant, utc, shanghai) => {
188+
expect(weekOf(bucketDateKey(instant, 'week'))).toEqual(utc);
189+
expect(weekOf(bucketDateKey(instant, 'week', 'Asia/Shanghai'))).toEqual(shanghai);
190+
});
191+
});
192+
193+
describe('[#20599] bucketKeyToCalendarRange spans a key in 0001..0099 in its own year', () => {
194+
it.each([
195+
['0001', '0001-01-01', '0002-01-01'],
196+
['0050', '0050-01-01', '0051-01-01'],
197+
['0099', '0099-01-01', '0100-01-01'],
198+
['0100', '0100-01-01', '0101-01-01'],
199+
['2026', '2026-01-01', '2027-01-01'],
200+
])('year %s', (key, start, end) => {
201+
expect(bucketKeyToCalendarRange(key, 'year')).toEqual({ start, end });
202+
});
203+
204+
it.each([
205+
['0001-Q2', '0001-04-01', '0001-07-01'],
206+
['0050-Q1', '0050-01-01', '0050-04-01'],
207+
['0050-Q4', '0050-10-01', '0051-01-01'],
208+
['0099-Q4', '0099-10-01', '0100-01-01'],
209+
['0100-Q4', '0100-10-01', '0101-01-01'],
210+
['2026-Q4', '2026-10-01', '2027-01-01'],
211+
])('quarter %s', (key, start, end) => {
212+
expect(bucketKeyToCalendarRange(key, 'quarter')).toEqual({ start, end });
213+
});
214+
215+
it.each([
216+
['0001-02', '0001-02-01', '0001-03-01'],
217+
['0050-01', '0050-01-01', '0050-02-01'],
218+
['0050-12', '0050-12-01', '0051-01-01'],
219+
['0099-12', '0099-12-01', '0100-01-01'],
220+
['0100-12', '0100-12-01', '0101-01-01'],
221+
['2026-12', '2026-12-01', '2027-01-01'],
222+
])('month %s', (key, start, end) => {
223+
expect(bucketKeyToCalendarRange(key, 'month')).toEqual({ start, end });
224+
});
225+
226+
it.each([
227+
['0001-01-01', '0001-01-02'],
228+
['0048-02-29', '0048-03-01'],
229+
['0050-01-01', '0050-01-02'],
230+
['0050-12-31', '0051-01-01'],
231+
['0099-12-31', '0100-01-01'],
232+
['0100-01-01', '0100-01-02'],
233+
['2026-02-28', '2026-03-01'],
234+
])('day %s', (key, end) => {
235+
expect(bucketKeyToCalendarRange(key, 'day')).toEqual({ start: key, end });
236+
});
237+
238+
it('still refuses an impossible day in 0001..0099 rather than rolling it over', () => {
239+
expect(bucketKeyToCalendarRange('0050-02-29', 'day')).toBeNull(); // 0050 is not a leap year
240+
expect(bucketKeyToCalendarRange('0099-04-31', 'day')).toBeNull();
241+
expect(bucketKeyToCalendarRange('0100-02-29', 'day')).toBeNull(); // 0100 is not a leap year
242+
expect(bucketKeyToCalendarRange('2026-02-29', 'day')).toBeNull();
243+
});
244+
245+
// The week arm validates a reconstructed Monday against the week LABEL,
246+
// which is spelled unpadded below the year 1000 (the unpadded-key family,
247+
// left alone here), so it is pinned on the 2026 control only.
248+
it('week 2026-W01 (the control)', () => {
249+
expect(bucketKeyToCalendarRange('2026-W01', 'week')).toEqual({ start: '2025-12-29', end: '2026-01-05' });
250+
});
251+
});
252+
});

0 commit comments

Comments
 (0)