Commit 3a89d45
Fixes #20550
Clause-②: no
`nextUtcCalendarDay` (`packages/spec/src/data/calendar-day.ts`) proves a
bare `YYYY-MM-DD` is a real day by building it and reading it back. It
built the date with `Date.UTC(y, mo - 1, d)`, and `Date.UTC` reads a
year from 0 to 99 as 1900 + year, so `0050-01-01` was built as
`1950-01-01`, the round trip failed, and the helper answered `null` for
every day of the years 0001..0099. `utcInstantMs` asks the same helper
about a bare day, so it answered `null` for them too. A `datetime`
`$lte` or `$between` maximum on such a day then skipped whole-day
widening (ADR-0053 D-D) and stopped at the day's first instant.
The accept set is unchanged: a query on years 0001..0099 now answers
what the contract already says (the claim's `Clause-②: no`).
## The change
- `packages/spec/src/data/calendar-day.ts`: a private `utcMidnight(year,
monthIndex, day)` builds the date as `new Date(0)` plus
`setUTCFullYear(year, monthIndex, day)`, which takes the year as written
and rolls the month and day over exactly as `Date.UTC` does.
`nextUtcCalendarDay` uses it for both the round trip and the next day.
The impossible-day refusal is unchanged in kind: `2026-02-30`,
`0050-02-30` and `0100-02-29` still roll over and are refused by the
round trip.
- `utcInstantMs` is byte-identical. Its bare-day arm already delegated
the "is this a real day" question to `nextUtcCalendarDay` and then read
the instant with `Date.parse` of ISO text, which reads year 0050
correctly; its timestamp arm never touched `Date.UTC`. So the one
construction fixes both helpers. No other helper in the file builds a
date.
- No edit under `packages/core/**` or `packages/drivers/**`: every
caller imports these helpers from `@objectstack/spec` (core re-exports
them).
## Verification (final head `6a5dce7378`, base `f11b5f20a2`)
Premise, measured before editing:
1. **Helper, on the base.** Instrument: `tsx` importing
`packages/spec/src/data/calendar-day.ts` at `f11b5f20a2`.
`nextUtcCalendarDay('0050-01-01')` = `null`,
`utcInstantMs('0050-01-01')` = `null`; the same for `0001-01-01` and
`0099-12-31`. Control: `'0100-01-01'` answers `'0100-01-02'` and
`-59011459200000`.
2. **Exhaustive, base against head.** Instrument: every string
`YYYY-MM-DD` with year 0000..9999, month 00..13 and day 00..32
(4,620,000 strings, 3,652,425 real days), compared with a
pure-arithmetic proleptic Gregorian oracle that uses no `Date`. Base:
36,525 mismatches for each helper, exactly every real day of the years
0000..0099. Head: 0 mismatches for either helper.
3. **Over REST, on the base semantics.** Instrument: the new
`packages/rest/src/data-query-calendar-day-year-below-100.test.ts`
through `POST /api/v1/data/:object/query`, process in
`America/New_York`, with `calendar-day.ts`'s construction put back to
`Date.UTC` (reverse verification below). SQLite and PostgreSQL 16
answered the same:
| `where opened_at` | base (SQLite, PostgreSQL) | head (SQLite,
PostgreSQL) |
|:--|:--|:--|
| `$lte '0050-01-01'` | `y49` | `y49`, `y50`, `y50_last` |
| `$between ['0050-01-01', '0050-01-01']` | none | `y50`, `y50_last` |
| `$lte '2026-07-15'` (control) | `c26`, `y49`, `y50`, `y50_last`,
`y50_next` | the same |
| `$between ['2026-07-15', '2026-07-15']` (control) | `c26` | the same |
Rows: `y49` = `0049-12-31T10:00Z`, `y50` = `0050-01-01T10:00Z` (the
card's row), `y50_last` = `0050-01-01T23:59:59.999Z`, `y50_next` =
`0050-01-02T00:00Z`, `c26` = `2026-07-15T14:00Z` (the card's control),
`c26_next` = `2026-07-16T00:00Z`. The next day's midnight stays out in
every cell.
Reverse verification (fix committed first, mutation through
`scripts/ablation-replace.mjs`, `trap` restore to the `HEAD` blob): the
`utcMidnight` body was replaced by `return new Date(Date.UTC(year,
monthIndex, day));`; the anchor fell 1 to 0 and the replacement rose 0
to 1 on disk; `pnpm --filter @objectstack/spec build`;
`scripts/ablation-dist-preflight.mjs` found the mutation in 4 built
files and the fix's line in none. Then `calendar-day.test.ts`: 3 failed
/ 7 passed (`0001-01-01: expected null to be '0001-01-02'`); the REST
file: 2 failed / 2 passed, the table's base column, identical on both
cells. Restore: blob `f03557d5bb` equals `HEAD`, whole-tree `git status
--porcelain` empty, a full spec rebuild, the preflight found the fix in
4 built files and the mutation in none, and both files went green again
(10/10, 4/4).
Tests at `6a5dce7378`:
- `pnpm --filter @objectstack/spec exec vitest run --project local
--maxWorkers=2`: 574 files, 16,883 passed, 1 todo.
- `@objectstack/rest`, the new file with the existing temporal suites
(`data-temporal-write-real-day-iso`, `data-temporal-year-range`,
`data-query-date-year-range`, `data-date-read-year-below-100`,
`data-date-write-iso-only`, `data-query-epoch-ms-date-comparand`,
`data-query-having-temporal-door`,
`aggregation-filter-temporal-storage-rule`,
`rest-14078-invalid-date-total-arm`), with a private PostgreSQL 16
server (zone `Asia/Shanghai`) as `OS_TEST_POSTGRES_URL`: 10 files, 89
passed, 5 skipped. The 5 skips are MySQL cells (`OS_TEST_MYSQL_URL`
unset). The new file ran on SQLite and PostgreSQL, the two drivers the
PR #20547 harness runs.
- `driver-sql` `sql-driver-calendar-day-upper-bound.test.ts` (SQLite and
PostgreSQL ran, MySQL not run): 15 passed. `driver-memory`: the six
`memory-analytics-date-range-*` suites and
`memory-driver-calendar-day-upper-bound.test.ts` (run, not edited): 7
files, 103 passed.
- `pnpm --filter @objectstack/spec typecheck` and `pnpm --filter
@objectstack/rest typecheck`: exit 0; `tsc --listFiles` over
`packages/rest/tsconfig.test.json` includes the new test file.
- Lint, narrowed: `eslint --no-inline-config --format json` over the
three touched `.ts` files: 3 files, 0 errors, 0 warnings. `eslint
--print-config` resolves a config for each of them, and
`eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`, no `projectService`), so the diff cannot move
any untouched file's verdict.
- Gates from `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands`: 83 commands, 81 exit 0. Two were
NOT MEASURED, `PREREQUISITE NOT MET` (exit 3), because they need the
whole workspace built: `pnpm check:dual-build-cjs-loads` (44 packages
without `dist/`) and `pnpm check:type-check-debt` (7 dependencies
without a built type entry). CI runs both.
Full-repo pin sweep: no test asserts `null` from either helper for a
year below 100 (`git grep` for both names applied to a `00NN-` literal:
0 hits), and no suite asserts a `$lte` / `$between` / date-range upper
bound on a `datetime` field in those years. So no pin had to change.
## Acceptance notes
- **Year 0000.** The new construction also answers for year 0000
(`0000-12-31` is followed by `0001-01-01`), where the base answered
`null`. It is not pinned: 0001..9999 is the supported range, and
`@objectstack/core`'s `isOutsideTemporalYearRange` refuses year 0000 at
the comparand and write doors before either helper is asked. The helper
does not keep a second copy of that range.
- **Memory driver**, through the engine at head (not REST;
`@objectstack/driver-memory` has no binding in `packages/rest`): `$lte
'0050-01-01'` answers `y49`, `y50`; the `$between` maximum answers
`y50`; the 2026 controls are unchanged.
- **Out of scope, reported to the seat and not changed here:** other
`Date.UTC` constructions read an author-given year below 100 as 1900 +
year, in `packages/core/src/utils/datetime.ts` and
`packages/rest/src/import-coerce.ts`. For example, the import door
stores a CSV `datetime` cell `0050-01-01 10:00` as
`1950-01-01T10:00:00.000Z`. Separately,
`nextUtcCalendarDay('9999-12-31')` answers `'10000-01-01'`, so on SQLite
a `datetime` `$lte '9999-12-31'` answers no rows. That is unchanged by
this PR and measured in the report.
---
_Generated by [Claude
Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92fe081 commit 3a89d45
4 files changed
Lines changed: 256 additions & 5 deletions
File tree
- .changeset
- packages
- rest/src
- spec/src/data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
Lines changed: 176 additions & 0 deletions
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
12 | | - | |
| 12 | + | |
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| |||
59 | 59 | | |
60 | 60 | | |
61 | 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
48 | | - | |
49 | | - | |
50 | | - | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
51 | 52 | | |
52 | | - | |
| 53 | + | |
53 | 54 | | |
54 | 55 | | |
55 | 56 | | |
| |||
103 | 104 | | |
104 | 105 | | |
105 | 106 | | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
106 | 124 | | |
107 | 125 | | |
108 | 126 | | |
| |||
0 commit comments