Commit a6866da
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
File tree
- .changeset
- packages
- core/src/utils
- rest/src
- services/service-analytics/src
- __tests__
- triggers/trigger-schedule/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
0 commit comments