You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 05a7547
Browse filesBrowse the repository at this point in the historyBrowse files
fix(core,objectql)!: a datetime names a year from 1000 to 9999 at both engine doors; a date keeps 0001..9999 (#20280) (#20843)
Fixes#20280
Clause-②: no (narrowing)
## What this changes
The `datetime` half of the card, by triage's answer A (comment
5905050200): **a `datetime` value names a year from 1000 to 9999**, and
one before year 1000 is refused at both engine doors. A `date` keeps
0001..9999, and a `time` column's range does not move.
- **One per-kind bound, in core's one range function.**
`packages/core/src/utils/temporal-storage-form.ts`:
`FIRST_SUPPORTED_YEAR` is now `{ date: 1, datetime: 1000 }`, and
`isOutsideTemporalYearRange` asks `FIRST_SUPPORTED_YEAR[kind]`. No
dialect switch: the range is the contract on every backend. The write
door (the record validator) and the comparand door both reach it through
`isUninterpretableTemporalComparand`, so neither door needed a second
edit to the range. The `date` rule's four-digit padding reads the `date`
bound (`FIRST_SUPPORTED_YEAR.date`), so it still pads 0001..0999.
- **The refusal words.**
`packages/objectql/src/temporal-comparand-door.ts`: the `datetime` year
class names 1000 to 9999. A `datetime` in 0001..0999 gets its own
consequence sentence, because it sorts and compares as the instant it
names on every backend. The misorder sentence ("does not sort as an
instant") would be false for it. The existing wire codes are unchanged:
`INVALID_FILTER` / 400 for a comparand, and `VALIDATION_FAILED` with the
field's `invalid_date` code for a written value.
- **The `time` class keeps its meaning.** A `time` refusal's class ("an
instant whose UTC year falls outside the years 0001 to 9999, so no time
of day is read from it") used to ask the `datetime` range. It now asks
the four-digit years explicitly, through core's `date` range over the
instant the `datetime` rule reads. The corpus below shows every `time`
answer byte-identical.
- **Comments that stated 0001..9999 for a `datetime`** are corrected in
`temporal-storage-form.ts`, `temporal-comparand.ts`, `datetime.ts`,
`record-validator.ts`, and one comment-only line in
`packages/spec/src/data/calendar-day.ts` (the spec lane's file; declared
here and in the report).
## The published accept sets that narrow (per door, per spelling)
Each one refuses a `datetime` whose UTC year falls in 0001..0999, and
accepted it at the base:
- **Write door** (`VALIDATION_FAILED` / `invalid_date`). Covers
`engine.insert`, `engine.update` (one row and many) and the dry-run
`engine.validate`, so also `POST /api/v1/data/:object`, `PATCH
/api/v1/data/:object/:id` and each row of `POST
/api/v1/data/:object/import`. Spellings: an ISO instant string, a bare
`YYYY-MM-DD` (midnight UTC), a zone-naive `YYYY-MM-DD HH:MM`, and a
`Date`. A number was already refused there, because a `datetime` is
stored as text, so no number spelling narrows at this door.
- **Comparand door** (`INVALID_FILTER` / 400). Covers `where`, a
per-aggregation `filter` and `having`, on the engine and on `POST
/api/v1/data/:object/query`. Spellings: a string, a `Date` and a number
(epoch milliseconds). The analytics native-SQL strategy asks the same
predicate (`service-analytics` `comparand-shape.ts`), so it declines
such a comparand and the query reaches this door. That is read from
source; its suite is green.
- The year is the UTC year: `1000-01-01T00:00:00+08:00` (year 999 in
UTC) is refused, and `0999-12-31T23:00:00-02:00` (year 1000 in UTC) is
read.
Nothing widens: across the corpus below, no answer moved from refused to
accepted.
## Before and after, measured (scratch harness, not committed)
Every door answer went through the public engine API on a recording
driver: `find` (`where`), `aggregate` (a per-aggregation `filter`, and
`having` on `min`), `validate`, `insert` and `update`. The fields were a
`date`, a `datetime` and a `time`. There were 69 values: 12 years, from
-1 and 0 up to 9999 and 10000, each as a string, a number and a `Date`,
plus zone-crossing offsets, impossible days, non-ISO spellings, bare
days, zone-naive stamps, bare-integer strings, placeholders and junk.
That is 1242 answers. Base `4b45afaed5` against head `65728398b2` (the
source commit; later commits are tests and the changeset):
- `date` field: **0 of 414 answers moved.** `time` field: **0 of 414
moved.**
- `datetime` field: **189 moved.** 126 are narrowings over 24 values in
0001..0999, in every spelling (108 accepted-to-refused, plus 18
`validate` verdicts from valid to invalid). 63 are refusals whose words
moved: the range now reads 1000 to 9999. Six of those, in 0001..0999,
were already refused for their spelling or day (`0500-02-30T10:00:00Z`,
`0500/07/15 10:00`, a bare-integer string) and are now named by their
year class first, as a year past 9999 already was.
- 1000 and 9999, in every spelling, answer as before (accepted).
## Stored rows before the floor (ADR-0087: refused with guidance, never
shifted)
Nothing rewrites a stored `datetime` below 1000. It reads back as
before; on MySQL a year in 0001..0099 still presents a century late
(ADR-0053 D-F2; ruling 5859414357, C: no mysql2 parser change). An
operator finds such rows with `$lt` on `1000-01-01T00:00:00.000Z`, a
comparand both doors admit. A PATCH that leaves the field out is
accepted. A write that carries a year below 1000 is refused, so the
field is written again with an instant from year 1000 on, or `null`.
`packages/rest/src/data-date-read-year-below-100.test.ts` pins exactly
this at the REST door: the census, the refused comparand below the
floor, a PATCH of another field on such a row, a refused PATCH that
keeps the year, and an accepted PATCH onto 1000. The changeset carries
one ADR-0087 marker, `not-required (no-migration-prescription)`: the
change refuses a value, and no key, export or stored metadata shape
moves. Its body has no FROM/TO table and no arrow.
## Tests
All at head `a3afb404b6` (after merging `origin/main` `b28054654d`),
process `TZ=America/New_York`:
- `@objectstack/core` 60 files, 1729 passed · `@objectstack/objectql`
344 files, 6773 passed · `@objectstack/driver-memory` 65 files, 1470
passed · `@objectstack/service-analytics` 141 files, 3267 passed.
- `@objectstack/driver-sql` 209 passed, 3 skipped files (4047 passed, 95
skipped), on SQLite and on a private PostgreSQL 16.13 (server zone
Asia/Shanghai). `@objectstack/rest` 233 files, 4571 passed, 33 skipped,
on SQLite and the same PostgreSQL.
- **NOT MEASURED: live MySQL.** No MySQL server is available in this
container, so every MySQL cell ran as a named skip. `Temporal
Conformance (live PG + MySQL)` runs `driver-sql`'s year-range file
there, including its new year-1000 row.
- Typecheck, each exit 0: core, objectql, driver-memory, driver-sql,
rest and spec, each with its `check:test-typecheck` test layer held.
- **Ablation of the bound** (run at `d767c1ca81`; the merge did not
touch the file, blob `17abe8b044` both sides):
- `node scripts/ablation-replace.mjs` set `{ date: 1, datetime: 1000 }`
back to `{ date: 1, datetime: 1 }` (anchor 1 to 0, blob `17abe8b044` to
`7a7adacdb9`). Core was rebuilt, and `ablation-dist-preflight` found the
marker in `dist/index.js` and `dist/index.cjs` (exit 0, with
`--source-marker`).
- Result: core 3 failed of 109, objectql 7 failed of 69. Every red is a
floor pin: the `datetime` 0001..0999 refusals on both doors, the
`having` twin, and the year-0050 write control.
- Restore under trap: blob equals HEAD, `git diff HEAD` empty, core
rebuilt, preflight `--absent` exit 0 with a clean tree. Green again: 109
of 109 and 69 of 69.
- Lint: `eslint --no-inline-config --format json` over the 19 changed TS
files reported 19 files, 0 errors, 0 warnings. Population:
`eslint.config.mjs` line 971, `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`, covers all 19. Invariance:
the config enables no type-aware linting (its lines 327-328), so this
diff moves no untouched file's verdict.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` at `a3afb404b6`
derived 89. All were run with recorded exit codes. `--ran` reports "89
derived, 87 run, 2 NOT-MEASURED, 0 UNRUN". The two NOT MEASURED are
`check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3
(PREREQUISITE NOT MET: they read every package's `dist` and this
worktree built only the touched closures).
## The pins, and each one's fate
- **Flipped to refused**, with the 1000 edge added as the accepted
control:
- core `temporal-storage-form.test.ts` and `temporal-comparand.test.ts`;
- objectql `engine-temporal-year-range.test.ts` (both doors, every
position), `engine-aggregate-having-temporal-door.test.ts` (the year-1
control became a refusal row; year 1000 is read),
`engine-temporal-write-real-day-iso.test.ts` (the `0050-01-01T10:00:00Z`
write);
- rest `data-temporal-year-range.test.ts`,
`import-date-cell-iso-real-day.test.ts` (the `dt` cell `0001-01-01` is
now refused per row by the write door behind the reader),
`import-datetime-year-below-100.test.ts` (the reader layer is kept; the
import door refuses each row below 1000 and stores 1000 and 2026; the
round trip runs at 1000 and 2026), and
`data-query-calendar-day-year-below-100.test.ts` (the whole-day bound is
measured at `1000-01-01`; the card's `0050-01-01` bounds and row are
pinned as refusals).
- **Moved to `date`** as the control: the year-4 leap day in core, and
`date` 0001..0999 in every file above (still accepted).
- **Kept because they never pass a door:** driver-memory
`memory-20264-temporal-year-range.test.ts` and driver-sql
`sql-driver-20264-temporal-year-range.test.ts`. They write straight
through the driver, and their `datetime` rows below 1000 are what a row
stored before the floor holds. Headers are corrected, and a year-1000
edge row is added. Also kept: the #20599 / #20550 unit pins on parts,
week keys and calendar-day helpers (core
`datetime-year-below-100.test.ts`, service-analytics
`week-key-year-below-100.test.ts`, spec `calendar-day.test.ts`), since
no door is in their path.
- `data-date-read-year-below-100.test.ts` (rest) now writes its
`datetime` values below 1000 through the driver, as stored-before-floor
rows, and pins the operator path above.
## Acceptance notes
- **File surface.** Five REST test files went red and are rewritten
above, plus one comment line in a sixth (count corrected by the seat
after review 5910551203). REST's source is untouched. The dispatch's
surface named pins in core, objectql, driver-memory and driver-sql;
these are the same pins one door further out.
- **The write door's words name the kind, not the range.** The ruling
asks for a refusal "naming the kind and the range". The comparand door
names both. The write door answers the existing `invalid_date` sentence
("must be a valid datetime (ISO-8601)"), as it did for the 0001..9999
range: naming the range there needs a new `validation-message.ts` key in
`packages/spec`, outside this card's surface. Raised in the report.
- **Placeholders are not judged by the door, by design.** A
relative-date placeholder is stepped around ahead of its resolution, so
`{1977_years_ago}` on a `datetime` still reaches the driver. It compares
as the instant it names (year 49; measured, correct rows). The same path
lets placeholders that land past 9999 or before year 1 through, with
wrong answers. That class is older than this change; the measured case
is in the report.
- **Levels.** `minor` for `@objectstack/core` and
`@objectstack/objectql`, whose source moves. REST and the analytics
service narrow through them without a source change of their own, and
the changeset config's one version group moves them in lockstep.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(core,objectql)!: a `datetime` value names a year from 1000 to 9999 at both engine doors, so one before year 1000 is refused as a written value and as a filter comparand; a `date` keeps 0001 to 9999
7
+
8
+
Clause-②: no (narrowing)
9
+
10
+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a VALUE at the two engine doors: a datetime whose UTC year falls in 0001..0999, refused VALIDATION_FAILED (field code invalid_date) as a written value and INVALID_FILTER / 400 as a comparand. No authorable key, spelling, export or stored metadata shape moves: FieldSchema's datetime type, every object definition and every query shape parse as before, and @objectstack/core and @objectstack/objectql export nothing new and nothing less (isOutsideTemporalYearRange keeps its signature). A datetime already stored before year 1000 is record data, not metadata: nothing converts it, no conversion is registered, and it is never shifted; the body says how an operator finds such rows and that a write carrying one is refused, which is guidance about data, not a rewrite of any consumer's code or metadata. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers a value range and this diff adds none (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->
11
+
12
+
**BREAKING**: this narrows what the engine accepts as a `datetime`. The one range function both doors ask, `isOutsideTemporalYearRange` in `@objectstack/core`, now takes a lower bound per kind: a `datetime` starts at year 1000 (its UTC year), a `date` stays at 0001, and both still end at 9999. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
13
+
14
+
**What is refused now.** A `datetime` whose UTC year falls in 0001..0999, which both doors accepted before:
15
+
16
+
-**The write door** (the record validator), in every spelling it took: an ISO instant string, a bare `YYYY-MM-DD` (midnight UTC), a zone-naive `YYYY-MM-DD HH:MM`, and a `Date`. It answers `VALIDATION_FAILED` with the field's `invalid_date` code, on `engine.insert`, `engine.update` (one row or many) and the dry-run `engine.validate`, so on `POST /api/v1/data/:object`, `PATCH /api/v1/data/:object/:id` and each row of `POST /api/v1/data/:object/import` too. A number was already refused there, because a `datetime` is stored as text.
17
+
-**The comparand door** (the temporal-comparand door), in every spelling: an ISO string, a `Date` and epoch milliseconds as a number. It answers `INVALID_FILTER` / 400 at `where`, at a per-aggregation `filter` and at `having`, on the engine and on `POST /api/v1/data/:object/query`. The analytics native-SQL strategy asks the same predicate, so it declines such a comparand and the query reaches this door.
18
+
- The year is the instant's UTC year: `1000-01-01T00:00:00+08:00` is year 999 in UTC and is refused, and `0999-12-31T23:00:00-02:00` is year 1000 in UTC and is read.
19
+
20
+
**What an author sees.** The comparand refusal names the field, the value, its position and the range: "an instant whose UTC year falls outside the years 1000 to 9999, the years a datetime value may name". It then says why the floor sits at 1000, instead of the misorder words that a year past 9999 still gets: MySQL documents its `DATETIME` from year 1000 only, and reads one stored in the years 0001 to 0099 back a century late. A comparand in those years that was already refused for its spelling or its day (a non-ISO spelling, a day that does not exist) is now named by its year first, as one past 9999 already was. The write refusal is the existing `invalid_date` sentence for a `datetime` field.
21
+
22
+
**Why.** MySQL documents `DATETIME` from year 1000, and it reads a stored `DATETIME` in 0001..0099 back a century late through its client's instant parser (`0009-03-04 10:00` comes back as `2004-09-03T10:00Z`), which ADR-0053 D-F2 keeps. The range is the contract on every backend, so SQLite, PostgreSQL and the in-memory driver, which held these years, refuse them too. No writer or query of a `datetime` before year 1000 was found.
23
+
24
+
**A `datetime` already stored before year 1000.** Nothing rewrites it, and nothing shifts it into the range. It reads back as before, and on MySQL a year in 0001..0099 still presents a century late. To find such rows, filter the field with `$lt` on `1000-01-01T00:00:00.000Z`, the floor's first instant, which both doors admit; the comparison runs on the stored value, so it finds them on MySQL as well. An update that leaves the field out is accepted. A write that carries a year below 1000 is refused, so the field can be written again with an instant from year 1000 on, or with `null`, and the author decides which.
25
+
26
+
**Unchanged.** A `date` keeps 0001..9999, padded to four digits as before. A `time` column still reads the time of day of an instant in 0001..0999, and a `time` comparand refused for another reason keeps that reason's words. Every year from 1000 to 9999 on a `datetime`, and every refusal outside 0001..9999 on either kind, answers as before, apart from the range the words name.
0 commit comments