fix(driver-sql): a MySQL date reads back the day it stores, so a year below 100 no longer comes back a century late - #20306
Conversation
…100 presents the day it stores mysql2 rebuilt a DATE with Date.UTC(y, m-1, d), which reads a year 0..99 as 1900 + year: a stored 0009-03-04 was presented as 1909-03-04. The connection now asks mysql2 for the DATE text (dateStrings: ['DATE']), presented through toDateOnly / temporalStorageForm as PostgreSQL's day already is. DATETIME keeps the client parser's Date (ADR-0053 D-F2). Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…rect the pins that described the old fold Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
… live MySQL cells Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…20240 note it makes false Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…sql-date-read-century
…sing them Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 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 0756f83ada87b0ee156bba091ca94a53f9bb313e && git checkout 0756f83ada87b0ee156bba091ca94a53f9bb313e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a78f731add67eab50b3e969e8ad46e44d195ab12 c35bfbf2308f9bd89e774de9db321fdf72436ddf && git checkout -B drift-repro a78f731add67eab50b3e969e8ad46e44d195ab12 && git merge --no-ff c35bfbf2308f9bd89e774de9db321fdf72436ddf
node scripts/docs-audit/affected-docs.mjs --json a78f731add67eab50b3e969e8ad46e44d195ab12
|
Contract reviewServed-tier: Scope: PR #20306 (card #20280, ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…sql-date-read-century # Conflicts: # .changeset/20240-date-year-four-digits.md
Contract reviewServed-tier: Scope: PR #20306 (card #20280, The delta, verified from git. ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Part of #20280 — the
datehalf. Thedatetimehalf stays open on the card: it needs a decision about ADR-0053 D-F2 (see "Open: the datetime half" below), so merging this must leave #20280 open.Clause-②: no
What was wrong
On MySQL, driver-sql took mysql2's JS
Datefor aDATEcolumn. mysql2 3.23.1Packet#parseDaterebuilds it asnew Date(Date.UTC(y, m - 1, d))under the driver'stimezone: 'Z'pin (new Date(y, m - 1, d)under'local'), and both constructors read a year from 0 to 99 as 1900 + year. The write was right and the read was wrong:where placed_on $eq '0009-03-04'found the row and presented1909-03-04.What changed
packages/drivers/driver-sql/src/sql-driver.ts: a newwithMysqlCalendarDayAsText, chained inwithConnectBoundbesidewithUtcSessionandwithPostgresCalendarDayAsText, setsdateStrings: ['DATE']on a MySQL connection (mysql/mysql2, object or URL connection) unless the host setdateStringsitself. A function-valued connection is left alone, aswithUtcSessionleaves it.datethroughtoDateOnly, which is@objectstack/core'stemporalStorageForm. With the wire text arriving, that rule's string arm hands back the storedYYYY-MM-DD: the same path PostgreSQL'sdatetakes since its calendar-day parser. No presenter changed and no copy of the rule was added.withPostgresCalendarDayAsTextandtoDateOnlyupdated: adatenow reaches the read doors as text on every dialect, MySQL included.Measured (live MySQL 8.0.46, server
time_zone='+08:00', processTZ=America/New_York, mysql2 3.23.1)Records written through
POST /api/v1/data/:object, read throughdriver.find/findOne,engine.find/findOne,POST …/queryandGET …/:id(all six agree in every cell). Base89f87f2344, head this branch.CAST(… AS CHAR))0009-03-041909-03-040009-03-040099-03-041999-03-040099-03-040999-06-150999-06-150999-06-150000-06-151900-06-150000-06-151000-01-011000-01-011000-01-012026-03-042026-03-042026-03-049999-12-319999-12-319999-12-310009-03-04 10:00:00.0002004-09-03T10:00:00.000Z0099-03-04 10:00:00.0001999-03-04T10:00:00.000Z0000-06-15 10:00:00.0002000-06-15T10:00:00.000ZA
groupBykey on thedatefield,distinct, andminmoved the same way (base1900-06-15/1909-03-04/1999-03-04; head0000-06-15/0009-03-04/0099-03-04).$eqand$gtfound the same rows at base and head on both kinds. Year 0000 is recorded, not decided: it is inside what this head stores and reads, and the range decision on #20264 refuses it once that lands.Remedy choice (the two measured on PR #20261's review)
Measured on a mysql2 connection per remedy, same rows:
DATE0009 / 0099 / 0000DATETIME0009 / 0099 / 0000DATE/DATETIMEtimezone: 'Z'(base)DateDate1899-11-30/ InvalidDatetimezone: '+00:00'DateDate/ InvalidDatedateStrings: ['DATE', 'DATETIME']dateStringsforDATEis taken. It is the narrower change: it touches onlyDATEcolumns and no write, and it is the shape PostgreSQL already reads a day in.'+00:00'would also move the zone every boundDateand everyDATETIMEis rendered in, and fixes nothing more.dateStringsforDATETIMEis NOT taken, because ADR-0053 D-F2 (accepted; anchored inscripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver.ts.json) says the mysql2 parser keeps materialising an instant as aDate, folded to text only at the driver's read doors. Text at the client parser for an instant is the ADR's "B1-full", an option not taken, and three landed pins encode it (sql-driver-13973-canonical-iso-read-door.test.ts§C,sql-driver-13567-audit-stamp-materialisation.test.ts§B3,sql-driver-14078-invalid-date-materialisation.test.ts§B2). Reversing that is an ADR decision, not a changeset.Open: the datetime half
A MySQL
DATETIMEin years 0001..0099 still reads a century late. No read-door repair exists (the fold is not invertible:2004-09-03may be a real stored day), so any fix is at the client parser. The options and their measured costs are in the report on the card; the tests here pin the currentdatetimereading as OBSERVED so a decision that moves it has to move them.Collateral (MySQL only)
date,datetime,time, aTIMESTAMPcolumn andnull, is byte-identical base to head onfind,findOne,count,aggregate(min,max,groupBy),distinctand a write-then-read (265 cells compared; the 25 that moved are exactly the year 0 / 9 / 99datecells above, theirgroupBy/distinct/minkeys, the rawexecute()DATE, and the new connection option).execute()read, and aDATEcolumn read under a field not declareddate, now receiveYYYY-MM-DDtext where they received aDate(measured:Date(2026-03-04T00:00:00.000Z)became"2026-03-04"). ADATETIMEthere is still aDate. PostgreSQL has answered adatethis way since its calendar-day parser.0000-00-00, only storable withNO_ZERO_DATEoff) presents as that text, where mysql2 invented1899-11-30. The zeroDATETIMEpin of The shared canonical-ISO normaliser turns an InvalidDatefrom a driver into a 500, whereString()served text #14078 is untouched (still an InvalidDate).withMysqlCalendarDayAsTextreturns their config object unchanged (pinned), and the whole driver-sql suite is green on both.Tests
packages/drivers/driver-sql/src/sql-driver-20280-mysql-date-read.test.ts: the connection option (URL and object, host-setdateStringskept, other dialects untouched,DATETIME/TIMESTAMPnot in the list), then per dialect cell of the live matrix: stored text by raw cast,find/findOne,groupBy/distinct/min/max,$eq/$gt, the raw wire (datetext everywhere,datetimeaDateon live cells), a create / update / read round trip of years 42 and 1, and thedatetimereading as observed.packages/rest/src/data-date-read-year-below-100.test.ts: the same read throughengine.find/findOne/aggregate/countandPOST /api/v1/data/:object,POST …/query,GET …/:id, on a SQLite cell (every runner) and a live MySQL cell (OS_TEST_MYSQL_URL; a named skip otherwise, a failure underOS_EXPECT_LIVE_DIALECT_MATRIX=1). No CI job runspackages/restagainst MySQL today, so that cell runs only where a server is provisioned; the CI-run pin of the same read is the driver-sql file underTemporal Conformance (live PG + MySQL).sql-driver-connect-bound.test.ts(the exact MySQL URL config gainsdateStrings),sql-driver-11389-date-tz-skew.test.ts(two comments, plus the option asserted),sql-driver-20240-date-year-spelling.test.ts(header no longer says MySQL reads a year below 100 a century late; the write-path cell now also reads the year-9 row back through the driver).Runs (live MySQL 8.0.46 on a private server at
+08:00, live PostgreSQL 16.13 atAsia/Shanghai,TZ=America/New_York,OS_EXPECT_LIVE_DIALECT_MATRIX=1):@objectstack/driver-sqlsuite ate9d1a26529: 203 files, 4669 passed, 1 skipped, "all 3 dialects were exercised"; the one test file changed after it (sql-driver-20280-mysql-date-read.test.ts, query options typed) re-run at93af6580d2: 25 passed on 3 dialects.data-query-date-year-range,data-query-epoch-ms-date-comparand, the new file) at93af6580d2: 3 files, 36 passed, SQLite and live MySQL cells.pnpm --filter @objectstack/driver-sql typecheckexit 0 (the new test is in thetscprogram,--listFilescount 1);pnpm --filter @objectstack/rest typecheckexit 0 (test layer: 0 files / 0 errors held).--no-inline-configon the 6 changed TypeScript files: 6 files, 0 errors, 0 warnings. That narrowing is complete:eslint.config.mjslints**/*.{ts,…}and enables no type-aware rule, so this diff cannot move a verdict on an untouched file.Ablation (committed first; restore proved by blob equal to
HEADand a cleangit status --porcelain): removing thewithMysqlCalendarDayAsTextcall fromwithConnectBounddist/rebuilt around the mutation (ablation-dist-preflightproved the call absent fromdist/in the mutate leg, present again after the restore rebuild): 4 failed / 6 passed, the four being everydatecell of the live MySQL cell.Changesets
.changeset/20280-mysql-date-read-text.md:@objectstack/driver-sqlpatch,Clause-②: no..changeset/20240-date-year-four-digits.md, one clause. It said a stored MySQL year below 100 "still reads back a century late … which this change does not touch", which this PR makes false for the release both notes ship in. It now reads "read back a century late … which this change does not touch 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, in the same release, corrects by reading a MySQLDATEas its text."check-empty-changesetis red on that file by design, and asks for exactly this: please confirm the correction.Acceptance notes
check:dual-build-cjs-loadsprinted PREREQUISITE NOT MET (it reads every package'sdist/; this worktree built only the driver-sql and REST closures): NOT MEASURED here, CI measures it.Generated by Claude Code