From 93f62a1269ed316a08e0f67586f54930d7b6cb2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:02:19 +0000 Subject: [PATCH 1/5] fix(driver-sql): read a MySQL DATE as its wire text, so a year below 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 --- packages/drivers/driver-sql/src/sql-driver.ts | 79 ++++++++++++++++++- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index bf1077542da..a6acc3cd35a 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -6372,7 +6372,9 @@ export class SqlDriver implements IDataDriver { // A function-valued `connection` (knex's per-acquire provider) is left // alone: the host is building each connection itself and owns its timeouts. } - return SqlDriver.withPostgresCalendarDayAsText(SqlDriver.withUtcSession(bounded)); + return SqlDriver.withPostgresCalendarDayAsText( + SqlDriver.withMysqlCalendarDayAsText(SqlDriver.withUtcSession(bounded)), + ); } /** @@ -6428,6 +6430,74 @@ export class SqlDriver implements IDataDriver { return out; } + /** + * The mysql2 column types handed back as the server's own text rather than + * as a JS `Date` — see {@link withMysqlCalendarDayAsText}. `DATE` only: + * `DATETIME` and `TIMESTAMP` are instants, and ADR-0053 D-F2 keeps the + * client parser's `Date` for them. + */ + private static readonly MYSQL_TEXT_TEMPORAL_TYPES: readonly string[] = ['DATE']; + + /** + * Keep a MySQL `DATE` a calendar-day STRING, never a JS `Date` (#20280). + * + * The MySQL counterpart of {@link withPostgresCalendarDayAsText}: the wire + * form of a `DATE` IS `YYYY-MM-DD`, so the driver asks mysql2 for that text + * (`dateStrings: ['DATE']`) and presents it through {@link toDateOnly} — + * `@objectstack/core`'s `temporalStorageForm`, the rule the write and + * `where` paths already apply. A `date` column then reaches the read doors + * as TEXT on every dialect. + * + * ## The measurement + * + * mysql2 3.23's `Packet#parseDate` rebuilds a `DATE` from its three numbers: + * `new Date(Date.UTC(y, m - 1, d))` under the `timezone: 'Z'` pin of + * {@link withUtcSession}, `new Date(y, m - 1, d)` under the default + * `'local'`. Both constructors read a year from 0 to 99 as 1900 + year. + * Measured on MySQL 8.0.46 (server `time_zone='+08:00'`) through `find()`: + * + * | stored (`CAST(d AS CHAR)`) | mysql2 materialised | presented before | presented now | + * |---|---|---|---| + * | `0009-03-04` | `1909-03-04T00:00:00.000Z` | `1909-03-04` | `0009-03-04` | + * | `0099-03-04` | `1999-03-04T00:00:00.000Z` | `1999-03-04` | `0099-03-04` | + * | `0999-06-15` | `0999-06-15T00:00:00.000Z` | `0999-06-15` | `0999-06-15` | + * | `2026-03-04` | `2026-03-04T00:00:00.000Z` | `2026-03-04` | `2026-03-04` | + * + * The write was right and the read was wrong: `where d $eq '0009-03-04'` + * found the row and presented `1909-03-04`. A year from 1000 to 9999 presents + * exactly what it did. A zero day (`0000-00-00`, storable only with + * `NO_ZERO_DATE` off) presents as that text, where mysql2 invented + * `1899-11-30`. + * + * ## Why `DATE` and not `DATETIME` + * + * `dateStrings` also takes `'DATETIME'`, and a `DATETIME` in years 0..99 is + * misread too: `parseDateTime` hands `'0009-03-04 10:00:00.000Z'` to V8's + * non-ISO `Date` parser, which answers 2004-09-03. But a `DATETIME` is an + * instant, and ADR-0053 D-F2 keeps the client parser's `Date` for an instant, + * folded to text only at the driver's own read doors — text at the client + * parser is the option that ADR did not take. That half is not decided here. + * + * The other measured remedy, a `'+00:00'` zone, takes mysql2's padded + * string-constructor arm for a `DATE` but keeps the `Date`, and moves the + * zone every bound `Date` and every `DATETIME` is rendered in. The text is + * the narrower change, and it is how PostgreSQL already reads a day. + * + * A host that set `dateStrings` itself is left alone, as + * {@link withUtcSession} leaves an explicit `timezone`; so is a + * function-valued `connection`, which the host builds per acquire. + */ + private static withMysqlCalendarDayAsText(knexConfig: Record): Record { + if (!SqlDriver.MYSQL_EMIT_CLIENTS.has(SqlDriver.clientSpelling(knexConfig))) return knexConfig; + + const conn = knexConfig.connection; + if (!conn || typeof conn !== 'object' || (conn as any).dateStrings !== undefined) return knexConfig; + return { + ...knexConfig, + connection: { ...(conn as object), dateStrings: [...SqlDriver.MYSQL_TEXT_TEMPORAL_TYPES] }, + }; + } + /** * knex client names that route to the `pg` npm driver, and therefore reach * {@link withPostgresCalendarDayAsText}. Wider than {@link isPostgres} on @@ -6507,7 +6577,9 @@ export class SqlDriver implements IDataDriver { * paths. Fixing it at the parser leaves exactly one clock in play, because * the driver then never produces a `Date` for a `date` column at all — which * is already how SQLite behaves (TEXT round-trip) and, via - * {@link withUtcSession}'s `timezone: 'Z'`, how mysql2 behaves. + * {@link withMysqlCalendarDayAsText}, how mysql2 behaves. (Before that, + * {@link withUtcSession}'s `timezone: 'Z'` kept mysql2's `Date` on the one + * UTC clock, but read a year below 100 a century late.) * * `pool.afterCreate` is the hook rather than a `pg.types.setTypeParser` * call because `setTypeParser` mutates the pg-types registry **process @@ -14658,7 +14730,8 @@ export class SqlDriver implements IDataDriver { * `new Date('2026-08-24')` comparand becomes `2026-08-23` west of UTC — the * identical one-day error, moved onto the write and filter paths). The read * path was fixed at its source instead — see - * {@link withPostgresCalendarDayAsText} — so on every dialect a `date` + * {@link withPostgresCalendarDayAsText} and, for MySQL, + * {@link withMysqlCalendarDayAsText} — so on every dialect a `date` * column now arrives here as TEXT and no driver-materialised `Date` reaches * this helper at all. ⛔ Do not "repair" a residual date skew by switching * the rule's UTC getters to their local twins; that reintroduces #11389 in the From 92a3d8b5ba6ecbbdc83f54b469064b6003eae14a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:06:47 +0000 Subject: [PATCH 2/5] test(driver-sql): pin the MySQL DATE read on the live matrix, and correct the pins that described the old fold Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../src/sql-driver-11389-date-tz-skew.test.ts | 16 +- ...ql-driver-20240-date-year-spelling.test.ts | 12 +- .../sql-driver-20280-mysql-date-read.test.ts | 213 ++++++++++++++++++ .../src/sql-driver-connect-bound.test.ts | 3 + 4 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sql-driver-20280-mysql-date-read.test.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts b/packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts index 3b446298bdb..baa5e1586fe 100644 --- a/packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts @@ -285,11 +285,13 @@ describe('#11389 — a Postgres `date` never becomes a JS Date', () => { expect((sqlite as any).knex.client.config.pool?.afterCreate).toBeUndefined(); // MySQL keeps exactly the UTC-session hook of #3942 — the date fix must not - // have displaced it, and mysql2 needs no parser override (its DATE arrives - // at UTC midnight because `withUtcSession` pins `connection.timezone: 'Z'`). + // have displaced it. Its DATE takes no type parser either: since #20280 the + // connection asks mysql2 for the DATE's wire text (`dateStrings`), which no + // process clock can move. const mysql = make({ client: 'mysql2', connection: 'mysql://u:p@host:3306/d' }); expect(typeof (mysql as any).knex.client.config.pool?.afterCreate).toBe('function'); expect((mysql as any).knex.client.config.connection.timezone).toBe('Z'); + expect((mysql as any).knex.client.config.connection.dateStrings).toEqual(['DATE']); }); }); @@ -422,10 +424,12 @@ function declareZoneSweep(cell: DialectCell): void { declareDialectCell(PG_CELL, 'date calendar-day zone invariance (#11389)', declareZoneSweep); // MySQL is in the matrix because it is the dialect that PROVES the asymmetry: -// mysql2 materialises a DATE at local midnight too, exactly like pg, and is -// nevertheless correct today because `withUtcSession` already pins -// `connection.timezone: 'Z'` (#3942). Losing that pin would reproduce #11389 -// one dialect over, and this cell is what would say so. +// under its default `'local'` zone mysql2 materialises a DATE at local +// midnight, exactly like pg. It was correct because `withUtcSession` pins +// `connection.timezone: 'Z'` (#3942), and since #20280 the connection asks for +// the DATE's wire text (`dateStrings`), which no zone reaches at all. Losing +// both pins would reproduce #11389 one dialect over, and this cell is what +// would say so. declareDialectCell(MYSQL_CELL, 'date calendar-day zone invariance (#11389)', declareZoneSweep); // ── The raw wire form, on a live server ───────────────────────────────────── diff --git a/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts b/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts index c833ad28927..e9154f6e50e 100644 --- a/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts @@ -25,12 +25,14 @@ * `9-03-04` as year 9 but stored `99-03-04` as 1999-03-04. * * The write path is asserted on the STORED text, read by a raw cast - * (`storedText`), never through the driver's read path. On MySQL that path - * hands a year below 100 back a century late: mysql2 rebuilds a `DATE` as + * (`storedText`), and not only through the driver's read path. On MySQL that + * path handed a year below 100 back a century late: mysql2 rebuilt a `DATE` as * `new Date(Date.UTC(y, m - 1, d))` (`parseDate`, with the driver's * `timezone: 'Z'`), and `Date.UTC` maps years 0..99 to 1900..1999, so a stored - * `0009-03-04` is presented as `1909-03-04`. That is a read-path defect of its - * own, unchanged by this card and reported beside it. Each row below asserts + * `0009-03-04` was presented as `1909-03-04`. That read-path defect was + * reported beside this card and closed by #20280, which reads a MySQL `DATE` + * as its wire text, so the write-path cell reads the year-9 row back through + * the driver on every dialect too. Each row below asserts * one answer for the number, its `Date` and its ISO string, plus a 2026 * control. A year below 0 or above 9999 is refused * as a comparand one layer up, at the engine's temporal-comparand door @@ -147,6 +149,8 @@ function measure(cell: DialectCell): void { await driver.update(WRITES, 'w2', { placed_on: new Date(Y0099) }, NO_AUDIT); expect(await stored('w1')).toBe('0009-03-04'); expect(await stored('w2')).toBe('0099-03-04'); + // [#20280] …and so does a year below 100, MySQL included. + expect((await driver.findOne(WRITES, { where: { id: 'w1' } }, NO_AUDIT))?.placed_on).toBe('0009-03-04'); // …so the stored day and a comparand for it are one day. expect(await ids(WRITES, { placed_on: { $eq: '0009-03-04' } })).toEqual(['w1']); expect(await ids(WRITES, { placed_on: { $eq: new Date(Y0099) } })).toEqual(['w2']); diff --git a/packages/drivers/driver-sql/src/sql-driver-20280-mysql-date-read.test.ts b/packages/drivers/driver-sql/src/sql-driver-20280-mysql-date-read.test.ts new file mode 100644 index 00000000000..29389727ab1 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-20280-mysql-date-read.test.ts @@ -0,0 +1,213 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20280] A MySQL `DATE` reads back the day it stores, in every year a + * `YYYY-MM-DD` day spells. + * + * mysql2 rebuilt a `DATE` from its three numbers, `new Date(Date.UTC(y, m - 1, + * d))` under the driver's `timezone: 'Z'`, and `Date.UTC` reads a year from 0 + * to 99 as 1900 + year. The write was right and the read was wrong. Measured on + * MySQL 8.0.46 (server `time_zone='+08:00'`), through the driver, the engine and + * `POST /api/v1/data/:object/query`, which present the same value: + * + * | stored `date` | presented before | presented now | + * |:--|:--|:--| + * | `0009-03-04` | `1909-03-04` | `0009-03-04` | + * | `0099-03-04` | `1999-03-04` | `0099-03-04` | + * | `0999-06-15` | `0999-06-15` | `0999-06-15` | + * | `2026-03-04` | `2026-03-04` | `2026-03-04` | + * + * The connection now asks mysql2 for the `DATE`'s wire text (`dateStrings: + * ['DATE']`, `withMysqlCalendarDayAsText`), and the read doors present it + * through `toDateOnly`, which is `@objectstack/core`'s `temporalStorageForm`. + * That is how PostgreSQL already reads a day. SQLite and PostgreSQL read these + * years right before and after, so their cells are the control. + * + * `datetime` is outside this change. A MySQL `DATETIME` in years 0..99 still + * reads a century late (`0009-03-04T10:00Z` as `2004-09-03T10:00Z`): mysql2's + * `parseDateTime` hands the wire text to V8's non-ISO `Date` parser. Fixing + * that means text (or a new parse) at the client parser, which ADR-0053 D-F2 + * declines for an instant, so it waits on a decision. The MySQL `datetime` + * cells below pin it as OBSERVED, beside the raw `Date` that D-F2 keeps, so a + * decision that moves it has to move this file too. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { SqlDriver } from './sql-driver.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const TABLE = 'os20280_days'; +const NO_AUDIT = { bypassTenantAudit: true }; + +/** id · the stored day · the stored instant */ +const ROWS = [ + { id: 'y9', placed_on: '0009-03-04', opened_at: '0009-03-04T10:00:00.000Z' }, + { id: 'y99', placed_on: '0099-03-04', opened_at: '0099-03-04T10:00:00.000Z' }, + { id: 'y999', placed_on: '0999-06-15', opened_at: '0999-06-15T10:00:00.000Z' }, + { id: 'y2026', placed_on: '2026-03-04', opened_at: '2026-03-04T10:00:00.123Z' }, +] as const; +const DAYS = ROWS.map((r) => r.placed_on); + +/** + * What a MySQL `DATETIME` in years 0..99 still reads as — the half this card + * does not decide (see the header). Observed, not desired. + */ +const MYSQL_DATETIME_FOLD: Record = { + y9: '2004-09-03T10:00:00.000Z', + y99: '1999-03-04T10:00:00.000Z', +}; + +/** The instant each row presents on this cell. */ +function presentedInstant(cell: DialectCell, row: (typeof ROWS)[number]): string { + return cell.id === 'mysql' ? (MYSQL_DATETIME_FOLD[row.id] ?? row.opened_at) : row.opened_at; +} + +/** knex's raw result shape differs per client; this is the only place that knows. */ +function rowsOf(cell: DialectCell, res: any): any[] { + if (cell.id === 'pg') return res?.rows ?? []; + if (cell.id === 'mysql') return Array.isArray(res) ? (res[0] ?? []) : []; + return Array.isArray(res) ? res : (res?.rows ?? []); +} + +describe('[#20280] a MySQL connection asks mysql2 for a DATE as its wire text', () => { + const drivers: SqlDriver[] = []; + const make = (config: any): SqlDriver => { + // knex builds its client eagerly and opens a pool connection only on the + // first query, so nothing here connects. + const d = new SqlDriver(config); + drivers.push(d); + return d; + }; + const connectionOf = (d: SqlDriver): any => (d as any).knex.client.config.connection; + afterAll(async () => { + for (const d of drivers) await d.disconnect().catch(() => {}); + }); + + it('a URL and an object connection both carry `dateStrings: [DATE]` beside the UTC pin', () => { + // `mysql2` only: knex loads the `mysql` package when it builds that client, + // and this workspace does not install it. + for (const connection of ['mysql://u:p@127.0.0.1:1/d', { host: '127.0.0.1', port: 1, database: 'd' }]) { + const conn = connectionOf(make({ client: 'mysql2', connection })); + expect(conn.dateStrings, typeof connection).toEqual(['DATE']); + expect(conn.timezone, typeof connection).toBe('Z'); + } + }); + + it('only `DATE`: an instant keeps the client parser, ADR-0053 D-F2', () => { + const conn = connectionOf(make({ client: 'mysql2', connection: 'mysql://u:p@127.0.0.1:1/d' })); + expect(conn.dateStrings).not.toContain('DATETIME'); + expect(conn.dateStrings).not.toContain('TIMESTAMP'); + }); + + it("leaves a host's own `dateStrings` alone, as `withUtcSession` leaves its `timezone`", () => { + for (const own of [true, false, ['DATE', 'DATETIME']]) { + const conn = connectionOf(make({ client: 'mysql2', connection: { host: '127.0.0.1', database: 'd', dateStrings: own } })); + expect(conn.dateStrings).toEqual(own); + } + }); + + it('touches no other dialect', () => { + for (const config of [ + { client: 'pg', connection: 'postgres://u:p@127.0.0.1:1/d' }, + { client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }, + ]) { + expect(connectionOf(make(config)).dateStrings, config.client).toBeUndefined(); + } + }); +}); + +function measure(cell: DialectCell): void { + describe(`[#20280] a date reads back the day it stores — ${cell.label}`, () => { + let driver: SqlDriver; + const ids = async (where: Record) => + (await driver.find(TABLE, { where } as any, NO_AUDIT)).map((r: any) => r.id).sort(); + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.initObjects([ + { name: TABLE, fields: { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } } }, + ] as any); + for (const row of ROWS) await driver.create(TABLE, { ...row }, NO_AUDIT); + }); + + afterAll(async () => { + await driver?.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver?.disconnect(); + }); + + it('the server stores each day as written — a raw cast, past every read path', async () => { + const sql = + cell.id === 'pg' + ? `select "id", "placed_on"::text as t from "${TABLE}"` + : cell.id === 'mysql' + ? `select \`id\`, cast(\`placed_on\` as char) as t from \`${TABLE}\`` + : `select "id", cast("placed_on" as text) as t from "${TABLE}"`; + const stored = Object.fromEntries(rowsOf(cell, await driver.execute(sql)).map((r) => [r.id, r.t])); + expect(stored).toEqual(Object.fromEntries(ROWS.map((r) => [r.id, r.placed_on]))); + }); + + it('find() and findOne() present the stored day', async () => { + const rows = await driver.find(TABLE, {} as any, NO_AUDIT); + expect(Object.fromEntries(rows.map((r: any) => [r.id, r.placed_on]))) + .toEqual(Object.fromEntries(ROWS.map((r) => [r.id, r.placed_on]))); + for (const row of ROWS) { + const one: any = await driver.findOne(TABLE, { where: { id: row.id } } as any, NO_AUDIT); + expect(one?.placed_on, row.id).toBe(row.placed_on); + } + }); + + it('a groupBy key, distinct() and min / max present the stored day', async () => { + const grouped = await driver.aggregate(TABLE, { + groupBy: ['placed_on'], + aggregations: [{ function: 'count', alias: 'n' }], + } as any); + expect(grouped.map((r: any) => r.placed_on).sort()).toEqual([...DAYS].sort()); + expect((await driver.distinct(TABLE, 'placed_on', undefined, NO_AUDIT)).sort()).toEqual([...DAYS].sort()); + const [range]: any[] = await driver.aggregate(TABLE, { + aggregations: [ + { function: 'min', field: 'placed_on', alias: 'first' }, + { function: 'max', field: 'placed_on', alias: 'last' }, + ], + } as any); + expect(range.first).toBe('0009-03-04'); + expect(range.last).toBe('2026-03-04'); + }); + + it('$eq finds each stored day, $gt orders by it, and the misread day finds nothing', async () => { + for (const row of ROWS) expect(await ids({ placed_on: { $eq: row.placed_on } }), row.id).toEqual([row.id]); + expect(await ids({ placed_on: { $gt: '0099-03-04' } })).toEqual(['y2026', 'y999']); + expect(await ids({ placed_on: { $eq: '1909-03-04' } })).toEqual([]); + }); + + it('a datetime is presented as before: right on SQLite and PostgreSQL, a MySQL year below 100 still folded (observed)', async () => { + const rows = await driver.find(TABLE, {} as any, NO_AUDIT); + expect(Object.fromEntries(rows.map((r: any) => [r.id, r.opened_at]))) + .toEqual(Object.fromEntries(ROWS.map((r) => [r.id, presentedInstant(cell, r)]))); + }); + + it('the raw wire: a date is text on every dialect, and a datetime stays the client\'s Date on a live one (ADR-0053 D-F2)', async () => { + const sql = + cell.id === 'mysql' + ? `select \`placed_on\`, \`opened_at\` from \`${TABLE}\` where \`id\` = 'y9'` + : `select "placed_on", "opened_at" from "${TABLE}" where "id" = 'y9'`; + const [raw] = rowsOf(cell, await driver.execute(sql)); + expect(raw.placed_on).toBe('0009-03-04'); + if (cell.live) { + expect(raw.opened_at instanceof Date, `${cell.label} raw datetime is ${typeof raw.opened_at}`).toBe(true); + expect((raw.opened_at as Date).toISOString()).toBe(presentedInstant(cell, ROWS[0])); + } else { + expect(typeof raw.opened_at).toBe('string'); + } + }); + + it('a write then a read round-trips a year below 100 — create, update, findOne', async () => { + await driver.create(TABLE, { id: 'rt', placed_on: '0042-01-31' }, NO_AUDIT); + expect((await driver.findOne(TABLE, { where: { id: 'rt' } } as any, NO_AUDIT) as any)?.placed_on).toBe('0042-01-31'); + await driver.update(TABLE, 'rt', { placed_on: '0001-12-31' }, NO_AUDIT); + expect((await driver.findOne(TABLE, { where: { id: 'rt' } } as any, NO_AUDIT) as any)?.placed_on).toBe('0001-12-31'); + }); + }); +} + +for (const cell of DIALECT_CELLS) declareDialectCell(cell, 'mysql date read (#20280)', measure); diff --git a/packages/drivers/driver-sql/src/sql-driver-connect-bound.test.ts b/packages/drivers/driver-sql/src/sql-driver-connect-bound.test.ts index 6eae73def1d..56c47251c80 100644 --- a/packages/drivers/driver-sql/src/sql-driver-connect-bound.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-connect-bound.test.ts @@ -75,6 +75,9 @@ describe('SqlDriver — connection-attempt bound (framework#3769)', () => { // `connection.timezone`, which defaults to the HOST's local zone. Pinned // to UTC so the recorded instant cannot depend on which machine wrote it. timezone: 'Z', + // #20280 — a `DATE` comes back as its `YYYY-MM-DD` wire text, which + // mysql2's `Date.UTC` rebuild read a century late below year 100. + dateStrings: ['DATE'], }); }); From e3670ff7670bbae87b26281fba14e705a82019c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:15:28 +0000 Subject: [PATCH 3/5] test(rest): pin the date read through the engine and REST, SQLite and live MySQL cells Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../src/data-date-read-year-below-100.test.ts | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 packages/rest/src/data-date-read-year-below-100.test.ts diff --git a/packages/rest/src/data-date-read-year-below-100.test.ts b/packages/rest/src/data-date-read-year-below-100.test.ts new file mode 100644 index 00000000000..859f739af2e --- /dev/null +++ b/packages/rest/src/data-date-read-year-below-100.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20280] A `date` field reads back the day it stores through the engine and + * REST, a year below 100 included, over a real `SqlDriver`. + * + * On MySQL the driver used to take mysql2's `Date` for a `DATE`, rebuilt as + * `new Date(Date.UTC(y, m - 1, d))`, which reads a year from 0 to 99 as + * 1900 + year. Measured on the base, on MySQL 8.0.46 (server + * `time_zone='+08:00'`), a record created through `POST /api/v1/data/:object`: + * + * | written `date` | stored | `engine.find`, `…/query`, `GET …/:id` | + * |:--|:--|:--| + * | `0009-03-04` | `0009-03-04` | `1909-03-04` | + * | `0099-03-04` | `0099-03-04` | `1999-03-04` | + * | `0999-06-15` | `0999-06-15` | `0999-06-15` | + * | `2026-03-04` | `2026-03-04` | `2026-03-04` | + * + * and a `groupBy` key, `min` and `$eq` agreed with the misread: `where + * placed_on $eq '0009-03-04'` found the row and presented `1909-03-04`. The + * driver now reads a MySQL `DATE` as its wire text, presented through + * `@objectstack/core`'s `temporalStorageForm`, so every door presents the + * stored day. + * + * Two cells. SQLite runs on every runner and is the control: it read these + * years right before the change. MySQL runs where `OS_TEST_MYSQL_URL` names a + * server, and is a named skip otherwise (a failure under + * `OS_EXPECT_LIVE_DIALECT_MATRIX=1`). ⚠️ No CI job runs this package against a + * MySQL server today, so the MySQL cell runs only where one is provisioned; the + * CI-run pin of the same read is driver-sql's + * `sql-driver-20280-mysql-date-read.test.ts` under `Temporal Conformance (live + * PG + MySQL)`, and every door here hands the driver's value through. + * + * `datetime` is outside the change: a MySQL `DATETIME` in years 0..99 still + * reads a century late, which waits on a decision about ADR-0053 D-F2 (the + * client parser keeps an instant's `Date`). Its MySQL cells are pinned as + * OBSERVED, so a decision that moves them has to move this file. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +const OBJECT = 'rest20280_days'; + +const DAYS_OBJECT = { + name: OBJECT, + label: 'Days', + fields: { + placed_on: { name: 'placed_on', type: 'date' as const }, + opened_at: { name: 'opened_at', type: 'datetime' as const }, + }, +}; + +/** id · the day and the instant written through REST create */ +const ROWS = [ + { id: 'y9', placed_on: '0009-03-04', opened_at: '0009-03-04T10:00:00.000Z' }, + { id: 'y99', placed_on: '0099-03-04', opened_at: '0099-03-04T10:00:00.000Z' }, + { id: 'y999', placed_on: '0999-06-15', opened_at: '0999-06-15T10:00:00.000Z' }, + { id: 'y2026', placed_on: '2026-03-04', opened_at: '2026-03-04T10:00:00.123Z' }, +] as const; +type Row = (typeof ROWS)[number]; + +/** What a MySQL `DATETIME` in years 0..99 still reads as. Observed, not desired. */ +const MYSQL_DATETIME_FOLD: Record = { + y9: '2004-09-03T10:00:00.000Z', + y99: '1999-03-04T10:00:00.000Z', +}; + +interface Cell { + readonly id: 'sqlite' | 'mysql'; + readonly label: string; + /** The environment variable that provisions this cell, or `null`. */ + readonly env: string | null; + readonly config: () => Record | null; +} + +const MYSQL_URL = process.env.OS_TEST_MYSQL_URL; +const EXPECT_LIVE_DIALECTS = process.env.OS_EXPECT_LIVE_DIALECT_MATRIX === '1'; + +const CELLS: readonly Cell[] = [ + { + id: 'sqlite', + label: 'sqlite', + env: null, + config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + }, + { + id: 'mysql', + label: 'live mysql', + env: 'OS_TEST_MYSQL_URL', + // A URL string, as the driver-sql matrix spells it: the driver's own + // connect handling keys off that shape. + config: () => (MYSQL_URL ? { client: 'mysql2', connection: MYSQL_URL } : null), + }, +]; + +function presentedInstant(cell: Cell, row: Row): string { + return cell.id === 'mysql' ? (MYSQL_DATETIME_FOLD[row.id] ?? row.opened_at) : row.opened_at; +} + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +function measure(cell: Cell, config: Record): void { + describe(`[#20280] a date reads back the day it stores, engine and REST — ${cell.label}`, { timeout: 60_000 }, () => { + let driver: any; + let engine: ObjectQL; + let call: (method: string, path: string, req: Record) => Promise; + + beforeAll(async () => { + driver = new SqlDriver(config as any); + await driver.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(DAYS_OBJECT as any); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const routes = rest.getRoutes(); + call = async (method, path, req) => { + const route = routes.find((r: any) => r.method === method && r.path === path); + expect(route, `${method} ${path}`).toBeDefined(); + const res = makeRes(); + // What the wire carries: JSON. + await route!.handler(JSON.parse(JSON.stringify(req)) as any, res); + return res; + }; + + for (const row of ROWS) { + const res = await call('POST', '/api/v1/data/:object', { params: { object: OBJECT }, body: { ...row } }); + expect(res._status, JSON.stringify(res._json)).toBe(201); + } + }, 60_000); + + afterAll(async () => { + await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + const byId = (rows: any[], key: 'placed_on' | 'opened_at') => + Object.fromEntries(rows.map((r: any) => [r.id, r[key]])); + const expectedDays = Object.fromEntries(ROWS.map((r) => [r.id, r.placed_on])); + + it('engine.find and engine.findOne present the stored day', async () => { + expect(byId(await engine.find(OBJECT, {}), 'placed_on')).toEqual(expectedDays); + for (const row of ROWS) { + expect((await engine.findOne(OBJECT, { where: { id: row.id } }))?.placed_on, row.id).toBe(row.placed_on); + } + }); + + it('POST …/query and GET …/:id present the stored day', async () => { + const q = await call('POST', '/api/v1/data/:object/query', { params: { object: OBJECT }, body: {} }); + expect(q._status ?? 200, JSON.stringify(q._json)).toBe(200); + expect(byId(q._json.records, 'placed_on')).toEqual(expectedDays); + for (const row of ROWS) { + const g = await call('GET', '/api/v1/data/:object/:id', { params: { object: OBJECT, id: row.id }, query: {} }); + expect(g._status ?? 200, JSON.stringify(g._json)).toBe(200); + expect((g._json.record ?? g._json).placed_on, row.id).toBe(row.placed_on); + } + }); + + it('a groupBy key and min / max present the stored day, on the engine and over REST', async () => { + const days = ROWS.map((r) => r.placed_on).sort(); + const grouped = { groupBy: ['placed_on'], aggregations: [{ function: 'count' as const, alias: 'n' }] }; + const range = { + aggregations: [ + { function: 'min' as const, field: 'placed_on', alias: 'first' }, + { function: 'max' as const, field: 'placed_on', alias: 'last' }, + ], + }; + expect((await engine.aggregate(OBJECT, grouped)).map((r: any) => r.placed_on).sort()).toEqual(days); + const [e] = await engine.aggregate(OBJECT, range); + expect([e.first, e.last]).toEqual(['0009-03-04', '2026-03-04']); + const g = await call('POST', '/api/v1/data/:object/query', { params: { object: OBJECT }, body: grouped }); + expect(g._json.records.map((r: any) => r.placed_on).sort()).toEqual(days); + const m = await call('POST', '/api/v1/data/:object/query', { params: { object: OBJECT }, body: range }); + expect([m._json.records[0].first, m._json.records[0].last]).toEqual(['0009-03-04', '2026-03-04']); + }); + + it('$eq on the stored day finds the row and presents that day; the misread day finds nothing', async () => { + for (const row of ROWS) { + const found = await engine.find(OBJECT, { where: { placed_on: { $eq: row.placed_on } } }); + expect(byId(found, 'placed_on'), `engine ${row.id}`).toEqual({ [row.id]: row.placed_on }); + const q = await call('POST', '/api/v1/data/:object/query', { + params: { object: OBJECT }, body: { where: { placed_on: { $eq: row.placed_on } } }, + }); + expect(byId(q._json.records, 'placed_on'), `REST ${row.id}`).toEqual({ [row.id]: row.placed_on }); + } + expect(await engine.count(OBJECT, { where: { placed_on: { $eq: '1909-03-04' } } })).toBe(0); + }); + + it('a datetime is presented as before: right on SQLite, a MySQL year below 100 still folded (observed)', async () => { + const expected = Object.fromEntries(ROWS.map((r) => [r.id, presentedInstant(cell, r)])); + expect(byId(await engine.find(OBJECT, {}), 'opened_at')).toEqual(expected); + const q = await call('POST', '/api/v1/data/:object/query', { params: { object: OBJECT }, body: {} }); + expect(byId(q._json.records, 'opened_at')).toEqual(expected); + }); + }); +} + +for (const cell of CELLS) { + const config = cell.config(); + if (config) { + measure(cell, config); + continue; + } + // Declared either way: a cell that emitted nothing would read as coverage. + describe(`[#20280] a date reads back the day it stores, engine and REST — ${cell.label}`, () => { + it.skipIf(!EXPECT_LIVE_DIALECTS)(`is provisioned — set ${cell.env} to run this cell`, () => { + expect.fail( + `${cell.env} is unset while OS_EXPECT_LIVE_DIALECT_MATRIX=1: this runner declared it ` + + `provisions a live server, so the ${cell.label} cell must not be skipped.`, + ); + }); + }); +} From f3afc10c480737e5343a52908ebe81c3c9ba25ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:16:26 +0000 Subject: [PATCH 4/5] chore(changeset): the MySQL date read, and one clause of the pending 20240 note it makes false Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .changeset/20240-date-year-four-digits.md | 2 +- .changeset/20280-mysql-date-read-text.md | 29 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 .changeset/20280-mysql-date-read-text.md diff --git a/.changeset/20240-date-year-four-digits.md b/.changeset/20240-date-year-four-digits.md index 7ec207b61e3..e243f7a1dbe 100644 --- a/.changeset/20240-date-year-four-digits.md +++ b/.changeset/20240-date-year-four-digits.md @@ -35,4 +35,4 @@ What changes: **Fix.** Compare against a `YYYY-MM-DD` day, or a number or `Date` whose UTC calendar day falls in a four-digit year. -**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included; every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 still reads back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch. `having` does not reach the temporal-comparand door for any comparand, so a number or `Date` outside 0..9999 there is still compared as written. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. +**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included; every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 read back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch and #20280, in the same release, corrects by reading a MySQL `DATE` as its text. `having` does not reach the temporal-comparand door for any comparand, so a number or `Date` outside 0..9999 there is still compared as written. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here. diff --git a/.changeset/20280-mysql-date-read-text.md b/.changeset/20280-mysql-date-read-text.md new file mode 100644 index 00000000000..c86edc0728e --- /dev/null +++ b/.changeset/20280-mysql-date-read-text.md @@ -0,0 +1,29 @@ +--- +"@objectstack/driver-sql": patch +--- + +fix(driver-sql): a MySQL `date` field reads back the day it stores, so a year below 100 no longer comes back a century late (#20280) + +Clause-②: no + +On MySQL the driver took mysql2's JS `Date` for a `DATE` column. mysql2 rebuilds it from the three stored numbers with `new Date(Date.UTC(y, m - 1, d))`, and `Date.UTC` reads 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, then presented it as `1909-03-04`. Measured on MySQL 8.0.46 (server `time_zone='+08:00'`), on records created through `POST /api/v1/data/:object`: + +| stored (`CAST(… AS CHAR)`) | `find` / `findOne`, the engine, `…/query`, `GET …/:id`, a `groupBy` key, `min`, `distinct`: before | now | +|:--|:--|:--| +| `0009-03-04` | `1909-03-04` | `0009-03-04` | +| `0099-03-04` | `1999-03-04` | `0099-03-04` | +| `0000-06-15` | `1900-06-15` | `0000-06-15` | +| `0999-06-15` | `0999-06-15` | `0999-06-15` | +| `2026-03-04` | `2026-03-04` | `2026-03-04` | + +The MySQL connection now asks mysql2 for a `DATE` as its `YYYY-MM-DD` wire text (`dateStrings: ['DATE']`), and the read doors present that text through `temporalStorageForm`, the rule the write and `where` paths already use. PostgreSQL has read a day as text the same way since its calendar-day parser. + +**Unchanged**, measured identical before and after on MySQL through the driver, the engine and REST: every read of a year from 1000 to 9999 on a `date`, `datetime` or `time` field, and of a `TIMESTAMP` column and a `null`, on `find`, `findOne`, `count`, `aggregate` (`min`, `max`, `groupBy`), `distinct` and a write-then-read; every `$eq` / `$gt` answer. SQLite and PostgreSQL reads do not move. + +**Also moved, on MySQL only:** + +- A raw `execute()` read, and a `DATE` column read under a field that is not declared `date`, now receive the `YYYY-MM-DD` text where they received a `Date` (a `datetime` column still arrives as a `Date`). PostgreSQL already answers a `date` column this way. +- A zero day (`0000-00-00`, storable only with `NO_ZERO_DATE` off) is presented as that text, where mysql2 made up `1899-11-30`. +- A connection whose host already set `dateStrings` is left as the host set it. + +**Not changed:** a `datetime` field. A MySQL `DATETIME` in years 0..99 still reads a century late (`0009-03-04T10:00:00.000Z` comes back as `2004-09-03T10:00:00.000Z`). ADR-0053 D-F2 keeps the client parser's `Date` for an instant, so that half stays open on #20280. From 93af6580d2df186e1f3dab007f14a86c9e2d4b83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:47:51 +0000 Subject: [PATCH 5/5] test(driver-sql): type the #20280 cells' query options instead of erasing them Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../sql-driver-20280-mysql-date-read.test.ts | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-20280-mysql-date-read.test.ts b/packages/drivers/driver-sql/src/sql-driver-20280-mysql-date-read.test.ts index 29389727ab1..cb5b439a10c 100644 --- a/packages/drivers/driver-sql/src/sql-driver-20280-mysql-date-read.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-20280-mysql-date-read.test.ts @@ -33,6 +33,8 @@ */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import type { FilterCondition } from '@objectstack/spec/data'; import { SqlDriver } from './sql-driver.js'; import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; @@ -119,8 +121,9 @@ describe('[#20280] a MySQL connection asks mysql2 for a DATE as its wire text', function measure(cell: DialectCell): void { describe(`[#20280] a date reads back the day it stores — ${cell.label}`, () => { let driver: SqlDriver; - const ids = async (where: Record) => - (await driver.find(TABLE, { where } as any, NO_AUDIT)).map((r: any) => r.id).sort(); + const ids = async (where: FilterCondition) => + (await driver.find(TABLE, { where }, NO_AUDIT)).map((r: any) => r.id).sort(); + const findOne = async (id: string): Promise => driver.findOne(TABLE, { where: { id } }, NO_AUDIT); beforeAll(async () => { driver = new SqlDriver(cell.config()); @@ -148,28 +151,29 @@ function measure(cell: DialectCell): void { }); it('find() and findOne() present the stored day', async () => { - const rows = await driver.find(TABLE, {} as any, NO_AUDIT); + const rows = await driver.find(TABLE, {}, NO_AUDIT); expect(Object.fromEntries(rows.map((r: any) => [r.id, r.placed_on]))) .toEqual(Object.fromEntries(ROWS.map((r) => [r.id, r.placed_on]))); for (const row of ROWS) { - const one: any = await driver.findOne(TABLE, { where: { id: row.id } } as any, NO_AUDIT); - expect(one?.placed_on, row.id).toBe(row.placed_on); + expect((await findOne(row.id))?.placed_on, row.id).toBe(row.placed_on); } }); it('a groupBy key, distinct() and min / max present the stored day', async () => { - const grouped = await driver.aggregate(TABLE, { + const byDay: DriverQuery = { groupBy: ['placed_on'], aggregations: [{ function: 'count', alias: 'n' }], - } as any); + }; + const grouped = await driver.aggregate(TABLE, byDay); expect(grouped.map((r: any) => r.placed_on).sort()).toEqual([...DAYS].sort()); expect((await driver.distinct(TABLE, 'placed_on', undefined, NO_AUDIT)).sort()).toEqual([...DAYS].sort()); - const [range]: any[] = await driver.aggregate(TABLE, { + const firstAndLast: DriverQuery = { aggregations: [ { function: 'min', field: 'placed_on', alias: 'first' }, { function: 'max', field: 'placed_on', alias: 'last' }, ], - } as any); + }; + const [range]: any[] = await driver.aggregate(TABLE, firstAndLast); expect(range.first).toBe('0009-03-04'); expect(range.last).toBe('2026-03-04'); }); @@ -181,7 +185,7 @@ function measure(cell: DialectCell): void { }); it('a datetime is presented as before: right on SQLite and PostgreSQL, a MySQL year below 100 still folded (observed)', async () => { - const rows = await driver.find(TABLE, {} as any, NO_AUDIT); + const rows = await driver.find(TABLE, {}, NO_AUDIT); expect(Object.fromEntries(rows.map((r: any) => [r.id, r.opened_at]))) .toEqual(Object.fromEntries(ROWS.map((r) => [r.id, presentedInstant(cell, r)]))); }); @@ -203,9 +207,9 @@ function measure(cell: DialectCell): void { it('a write then a read round-trips a year below 100 — create, update, findOne', async () => { await driver.create(TABLE, { id: 'rt', placed_on: '0042-01-31' }, NO_AUDIT); - expect((await driver.findOne(TABLE, { where: { id: 'rt' } } as any, NO_AUDIT) as any)?.placed_on).toBe('0042-01-31'); + expect((await findOne('rt'))?.placed_on).toBe('0042-01-31'); await driver.update(TABLE, 'rt', { placed_on: '0001-12-31' }, NO_AUDIT); - expect((await driver.findOne(TABLE, { where: { id: 'rt' } } as any, NO_AUDIT) as any)?.placed_on).toBe('0001-12-31'); + expect((await findOne('rt'))?.placed_on).toBe('0001-12-31'); }); }); }