fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) - #20746
Conversation
…t-year remap wallClockToUtcMs replaces Date.UTC at zonedWallClockToUtcMs (and its offset read), the ISO-week label, bucketKeyToCalendarRange, filter-tokens, service-analytics' week keys and trigger-schedule's day window. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…h Date.UTC Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…ger-schedule patch Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…c-instant-from-parts # Conflicts: # packages/services/service-analytics/src/preview-evaluator.ts
…c-instant-from-parts
📓 Docs Drift CheckThis PR changes 3 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 31 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c51899929d80e5eab145ff60cfcd8f26dc329925 && git checkout c51899929d80e5eab145ff60cfcd8f26dc329925
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 01e78dceeffb28477bcdbcab26f951b4cbef78ec da39ddacc0adb5624a5e3a84d379b16d6869e9c5 && git checkout -B drift-repro 01e78dceeffb28477bcdbcab26f951b4cbef78ec && git merge --no-ff da39ddacc0adb5624a5e3a84d379b16d6869e9c5
node scripts/docs-audit/affected-docs.mjs --json 01e78dceeffb28477bcdbcab26f951b4cbef78ec
|
Contract reviewServed-tier: Inputs: card #20599 (body and all five comments, 5895526582 included), PR #20746 (body, file list, Check-runs on the head, read by this act (2026-09-30T01:56Z) and not waited for: success — Auto Label, Dogfood Verify CLI, Dogfood Regression Gate 1/3 and 3/3, Build Core, Type Check · source gates, Type Check · debt ledger, Check Documentation Links, filter, Check Changeset, Check PR Size, Flag docs affected by code changes, Part-of PR must not also close its card, Governed Surface Queue Guard, No other open PR may claim the same single-writer path, No other open PR may claim the same issue, The card this PR closes must claim this branch; skipped — Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in); in_progress — Test Core 1/6 to 6/6, Dogfood Regression Gate 2/3, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Type Check · consumer gates, Type Check · workspace. None failed at read time. The landing rule's "every check green" stays the seat's to read when they finish. ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…port-year-pad Brings in PR #20746 (core builds a UTC instant from parts with wallClockToUtcMs), so the export -> import round trip for datetime years 0001..0099 can be measured against a base that reads the padded year right. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20599
Clause-②: yes
What changes
Date.UTC(year, …)andnew Date(year, …)read a year from 0 to 99 as 1900 + year (ECMA-262MakeFullYear). 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/coregains one root export,wallClockToUtcMs(parts: WallClockParts): number. It isDate.UTCwithout the remap, built asnew Date(0), thensetUTCFullYear, thensetUTCHours.monthis 1-12. Every component rolls over past its end the wayDate.UTCrolls it, and aNaNcomponent givesNaN. It sits besidezonedWallClockToUtcMsinutils/datetime.ts, and the root barrel'sexport *carries it, sopackages/core/src/index.tsis untouched.zonedWallClockToUtcMs(the wall clock and the zone-offset read), and through itzonedDateStartToUtcMs;isoWeekLabelFromCalendarDay;bucketKeyToCalendarRange; andfilter-tokens(proxyDay,startOfPeriod,daysInMonth,addMonthsClamped);service-analytics:preview-evaluator.tsbucketDate's week key, anddataset-executor.tsisoWeekKeyOfUtcMs. The census found the second one; the card did not list it;trigger-schedule:time-relative-trigger.tsstartOfUtcDay/endOfUtcDay.Intl'syearpart is an ERA year, so 1 BC reads1. 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_York0001-01-01 00:00is pinned: it gives0001-01-01T04:56:02.000Z.filter-tokensspells its day with core'stemporalStorageFormdaterule. Before, a hand-rolledymd()wrote the year unpadded. That spelling was unreachable for 0001..0099 whileDate.UTCthrew those years into the 1900s. This change makes it reachable:{1976_years_ago}would have become50-09-30, which names no day. So the fix pads it, and a macro step into 0100..0999 is padded too ({720000_days_ago}gives0055-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.tsis unchanged (H3). Its door is fixed through core.Census (before any fix, at
origin/main4dfff176b9)git grep -n "Date\.UTC("andgit grep -nE "new Date\(\s*[^)\"'\x60]*,"over non-test tracked files, all packages and scripts. That gave 49Date.UTC(lines and 6 multi-argumentnew Date(lines. Every multi-argumentnew Date(hit is a comment, or a single-argument call caught by the pattern.4dfff176b9)datetime.ts:143zonedWallClockToUtcMswall clockPOST /importdatetimecell, measured belowdatetime.ts:174offset readdatetime.ts:314/:317ISO-week labelbucketDateKey(week)of a stored year-50 instant (in-memory aggregation, analytics)datetime.ts:374–:414bucketKeyToCalendarRange0050from a SQL driver's bucket expression, drilledfilter-tokens.ts:198proxyDay,:215–:219startOfPeriodnow, the clock, at every caller (filterTokenContextFrom(ctx, new Date())or unset)filter-tokens.ts:225daysInMonthfilter-tokens.ts:243addMonthsClamped{N_months_ago}/{N_years_ago}; the grammar's N is unbounded (DATE_MACRO_PARAM_RE)service-analyticspreview-evaluator.ts:367week keyservice-analyticsdataset-executor.ts:841isoWeekKeyOfUtcMscompareToalignment on a week ordinal in year 50trigger-scheduletime-relative-trigger.ts:118/:123offsetDays/withinDaysare unbounded ints in spec, so an offset reaches 1..99formulastdlib.ts:59calendarDayUtcnow()only (the pinned evaluation clock)formuladepends onspecalone and cannot import coreformulastdlib.ts:102addMonthsUtcdaysInMonthexamples/app-todotask.functions.ts:47service-messagingpreference-resolver.ts:359nowMsclockservice-smssms-daily-quota.ts:141nowclockdriver-mongodbmongodb-pipeline-evaluator.testkit.ts:92/:147/:151calendar-day.ts:170, restimport-coerce.ts:453,driver-sqlsql-driver.ts:6892/:6893/:6989/:15303,driver-turso:71filter-number-comparand-declared-type.ts:909scripts/**(check-osv-exemptions,pm/*,qa/qa-rollup,sync-release-index-currency)Reach, measured at the door
The in-process route harness drives
POST /api/v1/data/:object/importand reads back throughPOST /api/v1/data/:object/queryover ObjectQL and SqlDriver. The base reading restorespackages/core/src/utils/{datetime,filter-tokens}.tsto4dfff176b9and rebuilds core; core is the only package on this door that the diff touches. The PostgreSQL 16.13 server ran attimezone=Asia/Shanghai.0050-01-01 10:00, no zone1950-01-01T10:00:00.000Z, ok 2 / errors 00050-01-01T10:00:00.000Z0050-01-01 10:00, Asia/Shanghai1950-01-01T02:00:00.000Z0050-01-01T01:54:17.000Z(LMT +08:05:43)2026-07-15 10:00control2026-07-15T10:00:00.000Z/…02:00:00.000Z0001/0050/0100at…-01-01T10:00Z, export as written1-01-01 …,50-01-01 …,100-01-01 …unpadded)1901-01-01T10:00Z,1950-01-01T10:00Z,0100exact; Asia/Shanghai:1950-01-01T10:05:43ZMechanism hypotheses (zone 2), as measured
datetime.ts, at the listed lines. The offset read also needed the zone's era for a year-0 probe.service-analyticsandtrigger-schedule;restneeds no import (H3);formulacannot import core, and needs no change: its two sites are clock-only or benign;zonedWallClockToUtcMswith no zone equals the helper, but only through its documented fallback.datetimepath goes throughDate.parse. A cell with a time goes throughzonedWallClockToUtcMs, which is now correct, soimport-coerce.tsis untouched.Date.UTChalf is gone:bucketDateKey('0050-01-01T10:00Z', week)is week 52 of 0049, not of 1949.bucketKeyToCalendarRangespans padded keys (0050,0050-Q4,0050-12,0050-01-01) in their own years. The round trip frombucketDateKeytobucketKeyToCalendarRangestill does not hold below year 1000, at any granularity:bucketDateKeyspells those years unpadded (50,50-Q1,50-01,50-01-01,49-W52), and the range function reads only\d{4};0050-W01answersnull;daysInMonth(day 0). The helper rolls identically. Nine rollover cases are pinned in 0001..0099, plus four 2026 cases equal toDate.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 andNaN;zonedWallClockToUtcMsandzonedDateStartToUtcMswith no zone, UTC, Asia/Shanghai and America/New_York;bucketDateKeyweek in UTC and Asia/Shanghai;bucketKeyToCalendarRangeyear, 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 previewbucketDateweek, and thecompareToordinals frombucketOrdinalOfDayandbucketKeyAtOrdinal.packages/triggers/trigger-schedule/src/time-relative-window-year-below-100.test.ts(20):offsetDaysandwithinDayswindows, and the claim scope.packages/rest/src/import-datetime-year-below-100.test.ts(74). This is thedomain:cliseat's round-trip pin, in theirexport-date-year-pad.test.tsharness pattern:parseDateCellon two hosts;POST /importof a CSV with no zone, UTC and Asia/Shanghai;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.Blocked-by: #20599); once it lands, the step changes nothing, and PR fix(rest): /export writes a date or datetime cell with a four-digit year, so an export of a year below 1000 re-imports (#20602) #20688 can drop its owndt: undefinedexclusion 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/)node scripts/ablation-replace.mjsreplaced the helper's body withDate.UTC(parts.year, …): anchor 1 → 0, blobfda5c68ba9bb→9d0def6aaa50. Thenpnpm --filter @objectstack/core build, andablation-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 weekexpected '1949-12-26' to be '0049-12-27', and windowgte '1950-09-30T00:00:00.000Z'. Every 0100, 2026,NaNand padding control stayed green. The direction was red, as predicted.fda5c68ba9bb, andgit diff HEADis 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.4dfff176b9(blob match: yes), rest's pin file gave 38 failed / 36 passed. Exactly the 0001, 0050 and 0099 rows failed;imports every rowstayed green, which is the silentok.Tests and gates (after the final commit,
da39ddacc0)pnpm --filter PKG test:bucketDateKeyand the filter tokens).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 typecheckis green for core, service-analytics, trigger-schedule and rest. Each program's--listFilesincludes the new test files.node scripts/pm/dispatch-gates.mjs --commands: 64 commands, all exit 0.check:dual-build-cjs-loadsandcheck:type-check-debtfirst answeredPREREQUISITE NOT MET(exit 3), and answered 0 afterturbo run build --filter='./packages/*' --filter='./packages/*/*'.--ran: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN..tsfiles, all matched byeslint.config.mjs'sfilesglobs;--format jsonread 10 files, 0 errors and 0 warnings;--print-configshows noparserOptions.projectorprojectService. 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.driver-sqlleg of Temporal Conformance. This container has no MySQL server, and nodriver-sqlsource imports a changed helper. CI runs it.Not in this PR
calendar-day.ts, which PR fix(spec): nextUtcCalendarDay and utcInstantMs read years 0001..0099 as written, not as 1900..1999 (#20550) #20591 already corrected.datecell year below 1000 unpadded (0500-01-01→500-01-01), so after PR #20524 a valid ISO date cell is refused per row asinvalid_date, and before it a non-day was stored #20534 / [finding]/exportwrites adate/datetimecell with a year below 1000 unpadded (0500-01-01→500-01-01), so the export does not re-import #20602), and MySQL's read-back (driver-sql on MySQL reads a year 0..99 back a century late — REST create storesplaced_on: "0009-03-04"correctly, and…/queryreturns"1909-03-04"; adatetime0009-03-04T10:00Zreturns2004-09-03T10:00Z#20280). [finding]/exportwrites adate/datetimecell with a year below 1000 unpadded (0500-01-01→500-01-01), so the export does not re-import #20602 and driver-sql on MySQL reads a year 0..99 back a century late — REST create storesplaced_on: "0009-03-04"correctly, and…/queryreturns"1909-03-04"; adatetime0009-03-04T10:00Zreturns2004-09-03T10:00Z#20280 are not addressed here.domain:cliseat. This PR removes theBlocked-bycause.Acceptance notes
bucketDateKey,isoWeekLabelFromCalendarDayand service-analyticsbucketKeyAtOrdinalspell 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, andbucketKeyToCalendarRange's week arm answersnulleven for a padded key. This belongs to the unpadded-year family of [finding]/exportwrites adate/datetimecell with a year below 1000 unpadded (0500-01-01→500-01-01), so the export does not re-import #20602.formulakeeps a private calendar-day copy (stdlib.ts:59), reached only throughnow(), and a day-count use ofDate.UTC(:102). Both read right for 1..99, so they are unchanged.examples/app-todohas the same day-count shape.driver-mongodb'smongodb-pipeline-evaluator.testkit.tsreads an ISO string throughDate.UTCand would model a year-50 instant in 1950. It is a test model, not product; noted with no carrier.calendarPartsInTzreadsIntl'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