diff --git a/.changeset/temporal-storage-form-axis-tests.md b/.changeset/temporal-storage-form-axis-tests.md new file mode 100644 index 0000000000..d74356bbd9 --- /dev/null +++ b/.changeset/temporal-storage-form-axis-tests.md @@ -0,0 +1,7 @@ +--- +--- + +Test-only: extend the ADR-0053 D-A3 storage-form axis to `Field.time` and the +wasm driver (#4191) — legacy-storage conformance sweeps in `driver-sql` and +`driver-sqlite-wasm`, plus doc-comment formalization of the axis in +`spec/data/temporal-conformance.ts`. Releases nothing. diff --git a/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts b/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts index fded3f1809..292cafc8c0 100644 --- a/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-temporal-conformance.test.ts @@ -14,7 +14,7 @@ * `Field.date` and coerces comparands accordingly. That the same case yields the * same row set here and in the type-blind backends is the whole assertion. * - * Three sweeps, because this driver owns two extra axes (#4081): + * Four sweeps, because this driver owns two extra axes (#4081): * 1. canonical storage — rows seeded through `create()` in their tagged * writer forms (ISO string vs JS `Date`, the D-E4 mixed-writer axis), * which `formatInput` must converge; @@ -23,7 +23,10 @@ * — the D-A3 "token → row results" axis; * 3. legacy storage — the same rows seeded RAW as pre-#3912 forms (INTEGER * epoch ms / zone-naive TEXT) with the canonical marker cleared, so the - * read-repair path answers the same table. + * read-repair path answers the same table; + * 4. the same for `Field.time` (#4191) — the pre-#3994 forms (INTEGER epoch + * ms / full-timestamp TEXT), so the repair path that closed #3994 has a + * regression lock of its own instead of riding the datetime sweep's. * * Runs against a real SQLite, and — through the `Temporal Conformance * (live PG + MySQL)` CI job, which runs this package's whole suite under @@ -186,3 +189,47 @@ describe('sql-driver — Field.time conformance', () => { }); } }); + +describe('sql-driver — Field.time conformance on un-backfilled legacy storage', () => { + let driver: LegacyStorageDriver; + + beforeAll(async () => { + driver = new LegacyStorageDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([ + { name: 'time_conformance', fields: { at: { type: 'time' }, why: { type: 'string' } } }, + ]); + // The two pre-#3994 storage forms, split by the same writer-form tag + // (the storage-form axis, #4191): `native` writes landed as INTEGER epoch + // ms of the wall clock on the epoch day — `a_midnight` is the measured + // hazard, INTEGER 0, which sorts before every TEXT row — and `wire` + // writes as full-timestamp TEXT still carrying a calendar day (pinned to + // the fixture's boundary day so every consumer seeds the same bytes). + // The read-repair path (`sqliteCanonicalTimeSql`) must answer the same + // table the canonical sweep does. + await driver.seedLegacyTimeRows( + 'time_conformance', + 'at', + TEMPORAL_TIME_ROWS.map((r) => ({ + id: r.id, + at: r.writerForm === 'native' ? Date.parse(`1970-01-01T${r.at}Z`) : `2026-07-28T${r.at}Z`, + why: r.why, + })), + ); + }); + + afterAll(async () => { + await driver.disconnect?.(); + }); + + for (const c of TEMPORAL_TIME_CASES) { + it(c.name, async () => { + const rows = await driver.find('time_conformance', { where: c.filter } as any); + const got = (rows as any[]).map((r) => r.id).sort(); + expect(got, c.note).toEqual([...c.expected].sort()); + }); + } +}); diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts index 1e8bb2857c..148409b102 100644 --- a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts +++ b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-temporal-conformance.test.ts @@ -10,6 +10,13 @@ * this driver ever stops sharing that seam — the same guard the #3773/#3777 * pin tests established one case at a time, now spanning the full matrix, * token spellings included. + * + * The legacy-storage sweeps (#4191) pin the OTHER inherited half: the + * read-repair path for rows written before the storage conventions existed + * (#3912 for `Field.datetime`, #3994 for `Field.time`) and never backfilled. + * Inheritance makes it easy to assume this half comes along for free — which + * is exactly why it gets its own describes: a wasm-side override of the knex + * seam that dropped the repair expression would fail HERE and nowhere else. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -26,6 +33,29 @@ import { SqliteWasmDriver } from './index.js'; const resolveTokens = (filter: T): T => resolveFilterTokens(filter, { now: new Date(TEMPORAL_NOW) }); +/** + * The wasm twin of driver-sql's `LegacyStorageDriver` testkit (deliberately + * unexported there — test-only): insert rows bypassing `formatInput` so each + * temporal value lands in the raw pre-convention form it is given, then clear + * the column's known-canonical marker so the read paths apply their repair — + * the state of an un-migrated (or backfill-failed, D-B3) database. + */ +class LegacyStorageWasmDriver extends SqliteWasmDriver { + async seedLegacyRows(table: string, field: string, rows: Array>): Promise { + await this.knex(table).insert(rows); + this.canonicalDatetimeFields[table]?.delete(field); + } + + async seedLegacyTimeRows(table: string, field: string, rows: Array>): Promise { + await this.knex(table).insert(rows); + this.canonicalTimeFields[table]?.delete(field); + } + + async destroy(): Promise { + await this.knex.destroy(); + } +} + describe('driver-sqlite-wasm — temporal conformance', () => { let driver: SqliteWasmDriver; @@ -108,3 +138,80 @@ describe('driver-sqlite-wasm — Field.time conformance', () => { }); } }); + +describe('driver-sqlite-wasm — temporal conformance on un-backfilled legacy storage', () => { + let driver: LegacyStorageWasmDriver; + + beforeAll(async () => { + driver = new LegacyStorageWasmDriver({ filename: ':memory:' }); + await driver.initObjects([ + { + name: 'conformance', + fields: { at: { type: 'datetime' }, on: { type: 'date' }, why: { type: 'string' } }, + }, + ]); + // The two pre-#3912 storage forms, split by the writer-form tag (the + // storage-form axis): `native` writes landed as INTEGER epoch ms, `wire` + // writes as zone-naive TEXT. The inherited read-repair must answer the + // same table the canonical sweep does. + await driver.seedLegacyRows( + 'conformance', + 'at', + TEMPORAL_ROWS.map((r) => ({ + id: r.id, + at: r.writerForm === 'native' ? Date.parse(r.at) : r.at.replace('T', ' ').replace('Z', ''), + on: r.on, + why: r.why, + })), + ); + }); + + afterAll(async () => { + await driver.destroy(); + }); + + // Literal spellings only: the token axis is orthogonal to storage form and + // already swept above — a divergence here is a repair-path bug by construction. + for (const c of TEMPORAL_CASES) { + it(c.name, async () => { + const rows = await driver.find('conformance', { where: c.filter } as any); + const got = (rows as any[]).map((r) => r.id).sort(); + expect(got, c.note).toEqual([...c.expected].sort()); + }); + } +}); + +describe('driver-sqlite-wasm — Field.time conformance on un-backfilled legacy storage', () => { + let driver: LegacyStorageWasmDriver; + + beforeAll(async () => { + driver = new LegacyStorageWasmDriver({ filename: ':memory:' }); + await driver.initObjects([ + { name: 'time_conformance', fields: { at: { type: 'time' }, why: { type: 'string' } } }, + ]); + // The pre-#3994 forms (#4191): `native` → INTEGER epoch ms of the wall + // clock on the epoch day (a_midnight = the measured hazard, INTEGER 0), + // `wire` → full-timestamp TEXT pinned to the fixture's boundary day. + await driver.seedLegacyTimeRows( + 'time_conformance', + 'at', + TEMPORAL_TIME_ROWS.map((r) => ({ + id: r.id, + at: r.writerForm === 'native' ? Date.parse(`1970-01-01T${r.at}Z`) : `2026-07-28T${r.at}Z`, + why: r.why, + })), + ); + }); + + afterAll(async () => { + await driver.destroy(); + }); + + for (const c of TEMPORAL_TIME_CASES) { + it(c.name, async () => { + const rows = await driver.find('time_conformance', { where: c.filter } as any); + const got = (rows as any[]).map((r) => r.id).sort(); + expect(got, c.note).toEqual([...c.expected].sort()); + }); + } +}); diff --git a/packages/spec/src/data/temporal-conformance.ts b/packages/spec/src/data/temporal-conformance.ts index fa5d0b9afc..b2ac642566 100644 --- a/packages/spec/src/data/temporal-conformance.ts +++ b/packages/spec/src/data/temporal-conformance.ts @@ -90,6 +90,38 @@ * can seed a genuinely mixed writer population through its own `create()` — * the assertion stays "which rows match", the convergence itself remains each * driver's own suite's claim. + * + * # The storage-form axis (D-B3/D-E3 → the D-A3 legacy sweep, #4191) + * + * Seeding through `create()` proves "what I write I can read back" — but two + * of the four incidents were about reading forms the CURRENT write path never + * produces: rows written before the convention existed and never backfilled + * (D-B3 allows the backfill to fail; D-E3 gives MongoDB no automatic backfill + * at all, so its legacy data is PERMANENT, not transitional). A consumer with + * a read-repair or convergence path therefore runs the same cases a SECOND + * time against rows injected BELOW its write path, in the raw forms the + * pre-convention writers left behind. + * + * The {@link TemporalWriterForm} tag is the per-row seeding basis for that + * sweep too — the same two populations, just left UNconverged: + * + * - `native` → the platform-native instant bound raw, in whatever shape the + * store kept it (INTEGER epoch ms on SQLite, a BSON `Date` on mongo, a JS + * `Date` in memory). For a {@link TemporalTimeRow} the instant is the epoch + * day's — `1970-01-01T${at}Z` — so `a_midnight` lands as the measured + * #3994 hazard: INTEGER `0`, which sorts before every TEXT row. + * - `wire` → raw text the write path did not rewrite: for `datetime` the + * zone-naive `YYYY-MM-DD HH:MM:SS` a `CURRENT_TIMESTAMP` default emitted; + * for `time` a full-timestamp spelling that still carries a calendar day — + * pin it to the fixture's boundary day (`2026-07-28T${at}Z`) so every + * consumer seeds the same bytes. + * + * The physical spelling is each driver's own suite's business (that is why no + * per-dialect values live here); what is shared is the acceptance rule: the + * legacy sweep must land on the same {@link TemporalCase.expected} row-id + * sets, cell for cell, as the canonical sweep. Any cell that differs means + * that backend's repair path has drifted from the consensus — the signal + * #3773 / #3994 / #4047 all lacked. */ import type { FilterCondition } from './filter.zod'; @@ -110,6 +142,12 @@ export const TEMPORAL_NOW = '2026-07-28T12:00:00.000Z'; * write delivers; `native` = a JS `Date`, what SDK callers and the drivers' * own timestamp defaults produce. Both writer populations appear inside AND * outside every window, so a backend that converges only one form fails. + * + * The same tag doubles as the seeding basis for the storage-form axis (see + * "The storage-form axis" in the module doc): a legacy sweep injects the + * SAME populations below the write path, unconverged — `native` as the raw + * platform-native instant, `wire` as the raw text spelling — and must reach + * the same expected row-id sets. */ export type TemporalWriterForm = 'wire' | 'native';