Skip to content

Commit fb6eaa6

Browse files
committed
test(rest,driver-memory,driver-sql): pin the 0001..9999 range at the public door and each dialect's edges (#20264)
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
1 parent f681849 commit fb6eaa6

3 files changed

Lines changed: 507 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20264] The supported years of a `date` and a `datetime` are 0001..9999.
5+
* The refusal outside them sits one layer up, at the engine's two doors (the
6+
* temporal-comparand door and the record validator, both asking
7+
* `@objectstack/core`'s `isOutsideTemporalYearRange`), so this driver's own
8+
* `where` and write paths are not a door and are not pinned as one. What this
9+
* driver owes the range is the other half: every year inside it is stored and
10+
* compared as the day or the instant it names, the edges included, beside a
11+
* 2026 control — and it reads the one rule, with no copy of its own.
12+
*
13+
* Measured on the base through this driver and the engine over it: a
14+
* `datetime` bound in year 10000 or −1 counted `$gt` / `$lt` / `$eq` 7 / 0 / 0
15+
* (its `+010000-…` / `-000001-…` text sorts below every four-digit year), and a
16+
* REST create stored a `date` of `"+010000-01-01T00:00:00.000Z"` verbatim.
17+
* Both are refused at the engine now; the engine's own suite and REST's pin
18+
* those cells.
19+
*/
20+
21+
import { beforeAll, describe, expect, it } from 'vitest';
22+
import type { FilterCondition } from '@objectstack/spec/data';
23+
import { temporalStorageForm } from '@objectstack/core';
24+
import { InMemoryDriver } from './memory-driver.js';
25+
26+
const OBJECT = 'ledger_year_20264';
27+
const FIELDS = { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } };
28+
29+
const ROWS = [
30+
{ id: 'first', placed_on: '0001-01-01', opened_at: '0001-01-01T00:00:00.000Z' },
31+
{ id: 'y0099', placed_on: '0099-03-04', opened_at: '0099-03-04T10:00:00.000Z' },
32+
{ id: 'y0999', placed_on: '0999-06-15', opened_at: '0999-06-15T10:00:00.000Z' },
33+
{ id: 'c2026', placed_on: '2026-02-01', opened_at: '2026-02-01T10:00:00.000Z' },
34+
{ id: 'last', placed_on: '9999-12-31', opened_at: '9999-12-31T23:59:59.999Z' },
35+
];
36+
37+
const ORDER = ['first', 'y0099', 'y0999', 'c2026', 'last'];
38+
39+
describe('[#20264] every year in 0001..9999 is stored and compared as the day or instant it names', () => {
40+
let driver: InMemoryDriver;
41+
const ids = async (where: FilterCondition) =>
42+
(await driver.find(OBJECT, { where })).map((r) => r.id as string).sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b));
43+
44+
beforeAll(async () => {
45+
driver = new InMemoryDriver({});
46+
await driver.connect();
47+
await driver.syncSchema(OBJECT, { name: OBJECT, fields: FIELDS });
48+
// Written as a number, a Date and a string — one stored form each.
49+
for (const [i, row] of ROWS.entries()) {
50+
const ms = Date.parse(row.opened_at);
51+
const opened_at = i % 3 === 0 ? ms : i % 3 === 1 ? new Date(ms) : row.opened_at;
52+
await driver.create(OBJECT, { ...row, opened_at });
53+
}
54+
});
55+
56+
it('reads each row back as written — the edges and the 2026 control', async () => {
57+
for (const row of ROWS) {
58+
expect(await driver.findOne(OBJECT, { where: { id: row.id } }), row.id).toMatchObject(row);
59+
}
60+
});
61+
62+
for (const [field, kind] of [['placed_on', 'date'], ['opened_at', 'datetime']] as const) {
63+
it(`${kind}: each value is found by $eq, and the range orders chronologically, in every spelling`, async () => {
64+
for (const [i, row] of ROWS.entries()) {
65+
const value = row[field];
66+
const ms = Date.parse(kind === 'date' ? `${value}T00:00:00.000Z` : value);
67+
for (const comparand of [value, ms, new Date(ms)]) {
68+
expect(await ids({ [field]: { $eq: comparand } }), `${row.id} $eq ${String(comparand)}`).toEqual([row.id]);
69+
expect(await ids({ [field]: { $gt: comparand } }), `${row.id} $gt ${String(comparand)}`).toEqual(ORDER.slice(i + 1));
70+
expect(await ids({ [field]: { $lt: comparand } }), `${row.id} $lt ${String(comparand)}`).toEqual(ORDER.slice(0, i));
71+
}
72+
}
73+
});
74+
}
75+
76+
it('the driver reads core\'s one rule: a year outside the range keeps the rule\'s spelling here, and the engine refuses it', async () => {
77+
// A direct write bypasses the engine's doors; what it stores is exactly
78+
// what `temporalStorageForm` spells — no copy of the rule in this driver.
79+
const y0 = new Date(Date.parse('0000-06-15T00:00:00.000Z'));
80+
const y10000 = Date.parse('+010000-01-01T00:00:00.000Z');
81+
await driver.create(OBJECT, { id: 'direct', placed_on: y0, opened_at: y10000 });
82+
const stored = await driver.findOne(OBJECT, { where: { id: 'direct' } });
83+
expect(stored?.placed_on).toBe(temporalStorageForm(y0, 'date'));
84+
expect(stored?.placed_on).toBe('0-06-15');
85+
expect(stored?.opened_at).toBe(temporalStorageForm(y10000, 'datetime'));
86+
await driver.delete(OBJECT, 'direct');
87+
expect(await ids({})).toEqual(ORDER);
88+
});
89+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20264] The supported years of a `date` and a `datetime` are 0001..9999, on
5+
* every dialect this driver speaks. The refusal outside them sits one layer up,
6+
* at the engine's two doors (the temporal-comparand door and the record
7+
* validator, both asking `@objectstack/core`'s `isOutsideTemporalYearRange`), so
8+
* this driver's `where` and write paths are not a door and are not pinned as
9+
* one. What each dialect owes the range is the other half: every year inside
10+
* it is STORED as the day or instant it names, and compared as it, the edges
11+
* included, beside a 2026 control.
12+
*
13+
* Measured on the base through the engine over this driver: a `datetime` bound
14+
* in year 10000 or −1 counted `$gt` / `$lt` / `$eq` 7 / 0 / 0 on SQLite and
15+
* answered 500 on PostgreSQL 16 (`22009` / `22007`) and MySQL 8.0; year 0
16+
* answered 500 on PostgreSQL on both kinds (`22008` — PostgreSQL's `DATE` and
17+
* `timestamptz` have no year 0); a REST create of a `date`
18+
* `"+010000-01-01T00:00:00.000Z"` was stored verbatim on SQLite and a 500 on
19+
* PostgreSQL and MySQL. The engine refuses each of those now.
20+
*
21+
* ## The one cell read-back does not assert: MySQL `DATETIME` in 0001..0099
22+
*
23+
* MySQL documents `DATETIME` from year 1000, and a `DATETIME` in years
24+
* 0001..0099 is stored right and read back a century late through mysql2's
25+
* instant parser (`0009-03-04 10:00` → `2004-09-03T10:00Z`), which ADR-0053
26+
* D-F2 keeps. Those years are inside the range, so the doors accept them; the
27+
* MySQL cell asserts their STORED text, and the misread is a decision returned
28+
* to the maintainer. From year 0100 up MySQL reads a `DATETIME` back as written,
29+
* which the cell asserts.
30+
*/
31+
32+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
33+
import type { FilterCondition } from '@objectstack/spec/data';
34+
import { SqlDriver } from './sql-driver.js';
35+
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
36+
37+
const TABLE = 'os20264_ledger';
38+
39+
const shape = (name: string) => ({
40+
name,
41+
fields: { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } },
42+
}) as any;
43+
44+
/** In chronological order on both fields: the edges, three early years and a 2026 control. */
45+
const ROWS = [
46+
{ id: 'first', placed_on: '0001-01-01', opened_at: '0001-01-01T00:00:00.000Z' },
47+
{ id: 'y0099', placed_on: '0099-03-04', opened_at: '0099-03-04T10:00:00.000Z' },
48+
{ id: 'y0100', placed_on: '0100-03-04', opened_at: '0100-03-04T10:00:00.000Z' },
49+
{ id: 'y0999', placed_on: '0999-06-15', opened_at: '0999-06-15T10:00:00.000Z' },
50+
{ id: 'c2026', placed_on: '2026-02-01', opened_at: '2026-02-01T10:00:00.000Z' },
51+
{ id: 'last', placed_on: '9999-12-31', opened_at: '9999-12-31T23:59:59.999Z' },
52+
];
53+
const ORDER = ROWS.map((r) => r.id);
54+
55+
const NO_AUDIT = { bypassTenantAudit: true };
56+
57+
/** knex's raw result shape differs per client; this is the only place that knows. */
58+
function rowsOf(cell: DialectCell, res: any): any[] {
59+
if (cell.id === 'pg') return res?.rows ?? [];
60+
if (cell.id === 'mysql') return Array.isArray(res) ? (res[0] ?? []) : [];
61+
return Array.isArray(res) ? res : (res?.rows ?? []);
62+
}
63+
64+
/** The text the server STORED for one row, by a raw cast — never the driver's read path. */
65+
async function storedText(driver: SqlDriver, cell: DialectCell, id: string, column: string): Promise<string | null> {
66+
const sql =
67+
cell.id === 'pg'
68+
? `select "${column}"::text as t from "${TABLE}" where "id" = ?`
69+
: cell.id === 'mysql'
70+
? `select cast(\`${column}\` as char) as t from \`${TABLE}\` where \`id\` = ?`
71+
: `select cast("${column}" as text) as t from "${TABLE}" where "id" = ?`;
72+
const rows = rowsOf(cell, await driver.execute(sql, [id]));
73+
expect(rows, `no stored row for ${id}`).toHaveLength(1);
74+
return rows[0].t ?? null;
75+
}
76+
77+
/** Does this cell read `opened_at` of `row` back as written? Every cell but MySQL below year 100. */
78+
const readsBackAsWritten = (cell: DialectCell, row: (typeof ROWS)[number]) =>
79+
!(cell.id === 'mysql' && Number(row.opened_at.slice(0, 4)) < 100);
80+
81+
function measure(cell: DialectCell): void {
82+
describe(`[#20264] the supported years 0001..9999 — ${cell.label}`, () => {
83+
let driver: SqlDriver;
84+
const ids = async (where: FilterCondition) =>
85+
(await driver.find(TABLE, { where }, NO_AUDIT)).map((r) => r.id as string)
86+
.sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b));
87+
88+
beforeAll(async () => {
89+
driver = new SqlDriver(cell.config());
90+
await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
91+
await driver.initObjects([shape(TABLE)]);
92+
for (const row of ROWS) await driver.create(TABLE, { ...row }, NO_AUDIT);
93+
});
94+
95+
afterAll(async () => {
96+
await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
97+
await driver.disconnect();
98+
});
99+
100+
it('a date in every year of the range is stored as that day and read back as written', async () => {
101+
for (const row of ROWS) {
102+
expect(await storedText(driver, cell, row.id, 'placed_on'), `stored ${row.id}`).toBe(row.placed_on);
103+
expect((await driver.findOne(TABLE, { where: { id: row.id } }, NO_AUDIT))?.placed_on, `read ${row.id}`).toBe(row.placed_on);
104+
}
105+
});
106+
107+
it('a datetime in every year of the range is stored as that instant, and read back as written save the MySQL 0001..0099 cell', async () => {
108+
for (const row of ROWS) {
109+
const read = (await driver.findOne(TABLE, { where: { id: row.id } }, NO_AUDIT))?.opened_at;
110+
if (readsBackAsWritten(cell, row)) {
111+
expect(read, `read ${row.id}`).toBe(row.opened_at);
112+
} else {
113+
// Stored right: the misread is the client parser's (see the module note).
114+
expect(await storedText(driver, cell, row.id, 'opened_at'), `stored ${row.id}`)
115+
.toBe(`${row.opened_at.slice(0, 10)} ${row.opened_at.slice(11, 23)}`);
116+
}
117+
}
118+
});
119+
120+
for (const field of ['placed_on', 'opened_at'] as const) {
121+
it(`${field}: each value is found by $eq and the range orders chronologically — the edges and the 2026 control`, async () => {
122+
for (const [i, row] of ROWS.entries()) {
123+
const value = row[field];
124+
expect(await ids({ [field]: { $eq: value } }), `${row.id} $eq`).toEqual([row.id]);
125+
expect(await ids({ [field]: { $gt: value } }), `${row.id} $gt`).toEqual(ORDER.slice(i + 1));
126+
expect(await ids({ [field]: { $lt: value } }), `${row.id} $lt`).toEqual(ORDER.slice(0, i));
127+
}
128+
});
129+
}
130+
});
131+
}
132+
133+
for (const cell of DIALECT_CELLS) declareDialectCell(cell, 'temporal year range', measure);

0 commit comments

Comments
 (0)