Skip to content
2 changes: 1 addition & 1 deletion .changeset/20240-date-year-four-digits.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
"@objectstack/core": minor
"@objectstack/objectql": minor
Expand Down Expand Up @@ -35,4 +35,4 @@

**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` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `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` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `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.
29 changes: 29 additions & 0 deletions .changeset/20280-mysql-date-read-text.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});

Expand Down Expand Up @@ -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 ─────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// 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 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';

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<string, string> = {
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: FilterCondition) =>
(await driver.find(TABLE, { where }, NO_AUDIT)).map((r: any) => r.id).sort();
const findOne = async (id: string): Promise<any> => driver.findOne(TABLE, { where: { id } }, NO_AUDIT);

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, {}, 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) {
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 byDay: DriverQuery = {
groupBy: ['placed_on'],
aggregations: [{ function: 'count', alias: 'n' }],
};
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 firstAndLast: DriverQuery = {
aggregations: [
{ function: 'min', field: 'placed_on', alias: 'first' },
{ function: 'max', field: 'placed_on', alias: 'last' },
],
};
const [range]: any[] = await driver.aggregate(TABLE, firstAndLast);
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, {}, 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 findOne('rt'))?.placed_on).toBe('0042-01-31');
await driver.update(TABLE, 'rt', { placed_on: '0001-12-31' }, NO_AUDIT);
expect((await findOne('rt'))?.placed_on).toBe('0001-12-31');
});
});
}

for (const cell of DIALECT_CELLS) declareDialectCell(cell, 'mysql date read (#20280)', measure);
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
});
});

Expand Down
Loading
Loading