diff --git a/.changeset/temporal-conformance-driver-axis.md b/.changeset/temporal-conformance-driver-axis.md new file mode 100644 index 0000000000..f1e6668508 --- /dev/null +++ b/.changeset/temporal-conformance-driver-axis.md @@ -0,0 +1,15 @@ +--- +--- + +Test-only: run the ADR-0053 D-A3 temporal conformance matrix across the DRIVER +axis the ADR declares — `driver {SQLite, Postgres at minimum}` — instead of four +hard-coded `better-sqlite3` drivers (#4245). All four sweeps (canonical +datetime, relative tokens, `Field.time`, legacy storage) now run once per cell +of `DIALECT_CELLS`: SQLite always, live Postgres and MySQL whenever +`OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are provisioned, over the same +`TEMPORAL_CASES` / `TEMPORAL_TIME_CASES` and asserting the same row-id sets cell +for cell. Adds the D-B3 server-timezone axis as an executed guard (server ≠ UTC +≠ process, and server ≠ process) so a cell cannot pass vacuously, and +`OS_EXPECT_LIVE_DIALECT_MATRIX=1` in the `Temporal Conformance (live PG + +MySQL)` CI job so a lost `OS_TEST_*_URL` is a red rather than a silent return to +SQLite-only coverage. Releases nothing. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c099036112..b6c1b40532 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -435,6 +435,14 @@ jobs: TZ: America/New_York OS_TEST_POSTGRES_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres OS_TEST_MYSQL_URL: mysql://root:root@127.0.0.1:3306/conformance + # The URL half of the same vacuous-pass hole the `node -e` below closes + # for the zone (#4245). The temporal conformance MATRIX now runs one + # cell per dialect (ADR-0053 D-A3 `driver {SQLite, Postgres at + # minimum}`), and a cell whose URL is missing is an ordinary skip — so + # losing either of the two lines above would silently return the matrix + # to SQLite-only coverage and stay green. This flag says "this runner + # provisioned the servers", which turns that skip into a named red. + OS_EXPECT_LIVE_DIALECT_MATRIX: '1' run: | # The axis is only real if the zone actually took. Without this, a # dropped `TZ:` line silently returns the job to UTC coverage and diff --git a/packages/plugins/driver-sql/src/legacy-datetime-storage.testkit.ts b/packages/plugins/driver-sql/src/legacy-datetime-storage.testkit.ts index 3227d25241..9b1be355b1 100644 --- a/packages/plugins/driver-sql/src/legacy-datetime-storage.testkit.ts +++ b/packages/plugins/driver-sql/src/legacy-datetime-storage.testkit.ts @@ -57,6 +57,25 @@ export class LegacyStorageDriver extends SqlDriver { this.canonicalTimeFields[table]?.delete(field); } + /** + * Does this dialect still owe a `Field.datetime` column the read-side legacy + * repair? (#4245) + * + * The driver's own rule, exposed so a matrix consumer can ASSERT which + * dialects have a legacy storage form instead of asserting it in a comment. + * True only on SQLite — the typeless store where INTEGER epoch ms can sit + * next to zone-naive TEXT in one column — and only while the column's + * canonical marker is clear, so call it after {@link forgetCanonical}. + */ + legacyDatetimeRepairApplies(table: string, field: string): boolean { + return this.needsLegacyDatetimeRepair(table, field); + } + + /** The `Field.time` twin of {@link legacyDatetimeRepairApplies} (#4245). */ + legacyTimeRepairApplies(table: string, field: string): boolean { + return this.needsLegacyTimeRepair(table, field); + } + /** Raw stored form of a column, for asserting on the fixture's premise. */ async storedForms(table: string, field: string): Promise> { const res: any = await this.knex.raw( diff --git a/packages/plugins/driver-sql/src/live-dialect-matrix.testkit.ts b/packages/plugins/driver-sql/src/live-dialect-matrix.testkit.ts new file mode 100644 index 0000000000..7eeb28c01e --- /dev/null +++ b/packages/plugins/driver-sql/src/live-dialect-matrix.testkit.ts @@ -0,0 +1,264 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The DRIVER axis of the shared conformance matrices (ADR-0053 D-A3: the matrix + * is `driver {SQLite, Postgres at minimum}` × …), plus the SERVER-TIMEZONE axis + * D-B3 added to it — one definition of "which backends does a matrix consumer + * run on, and what makes that run non-vacuous", so every consumer spells it the + * same way instead of hard-coding `client: 'better-sqlite3'` (#4245). + * + * Why this exists as a helper rather than a literal per suite: a hard-coded + * client is invisible. `sql-driver-temporal-conformance.test.ts` carried four of + * them while its own head note claimed it ran "against real Postgres and MySQL + * too" — declared ≠ enforced, and the ADR's `Postgres at minimum` never + * executed for the matrix at all. A cell list you have to *opt out of* fails + * loudly the moment a new sweep forgets a dialect. + * + * ## The non-vacuity contract + * + * A live cell proves nothing unless the three clocks actually disagree, which is + * exactly the configuration D-B2 measured the dialect divergence under: + * + * - the SERVER's timezone (CI: PG `Asia/Shanghai`, MySQL `+08:00`), + * - the PROCESS's timezone (CI: `TZ=America/New_York`), + * - UTC, which is what the canon says every stored instant and every comparand + * denotes. + * + * {@link assertThreeWayZoneSkew} asserts all three are pairwise different. On a + * UTC server, or a UTC process, the identical answers a green matrix reports are + * answers no timezone could have perturbed — a pass that means nothing. The + * guard turns that into a red with the fix in the message. + * + * ## Skips are visible, and can be made fatal + * + * Without `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` a live cell is reported + * as a named SKIP (never a silent pass). A runner that *knows* it provisioned + * the servers — the `Temporal Conformance (live PG + MySQL)` CI job — sets + * `OS_EXPECT_LIVE_DIALECT_MATRIX=1`, which turns a missing URL into a failure: + * without it, dropping the `env:` block from that job would silently return the + * whole matrix to SQLite-only coverage and stay green, which is the same + * vacuous-pass hole the job's own process-zone assertion closes. + * + * Test-only: not exported from `index.ts`. + */ + +import { expect } from 'vitest'; +import type { SqlDriver, SqlDriverConfig } from './sql-driver.js'; + +/** The dialects `driver-sql` speaks that the matrices are run across. */ +export type DialectId = 'sqlite' | 'pg' | 'mysql'; + +export interface DialectCell { + id: DialectId; + /** Human label, used in suite names. */ + label: string; + /** The env var that provisions this cell — `null` for the embedded SQLite one. */ + env: string | null; + /** Provisioned connection string, when this cell needs one. */ + url?: string; + /** Can this cell run right now? (SQLite always can.) */ + available: boolean; + /** Does the cell talk to a separate server that carries its own timezone? */ + live: boolean; + /** + * Can rows in a PRE-canonical storage form still exist on this dialect — i.e. + * does the driver keep a read-side repair for them? + * + * SQLite only, and not by convention: SQLite has no temporal type, so a + * pre-#3912/#3994 database really does hold INTEGER epoch ms next to + * zone-naive TEXT in one column, and `needsLegacyDatetimeRepair` / + * `needsLegacyTimeRepair` gate the repair on `isSqlite`. Postgres and MySQL + * store a real `timestamptz` / `DATETIME(3)` / `TIME(3)`, so their rows are + * already one shape and there is nothing on disk to repair — see + * `backfillCanonicalDatetimes`, which says exactly this and returns early. + * + * This flag is what a legacy sweep selects cells by, and consumers must ASSERT + * the driver agrees with it (see `LegacyStorageDriver.legacyDatetimeRepairApplies`) + * rather than trusting the constant — otherwise it decays into the same + * unverified claim the hard-coded client was. + */ + hasLegacyStorageForm: boolean; + /** Fresh driver config for this cell. */ + config(): SqlDriverConfig; +} + +const PG_URL = process.env.OS_TEST_POSTGRES_URL; +const MYSQL_URL = process.env.OS_TEST_MYSQL_URL; + +/** + * `1` when the runner has provisioned the live servers and a missing URL is + * therefore a defect in the runner, not a developer running without Docker. + */ +export const EXPECT_LIVE_DIALECTS = process.env.OS_EXPECT_LIVE_DIALECT_MATRIX === '1'; + +/** + * Every cell of the driver axis, available or not — a consumer iterates the + * whole list so an unprovisioned dialect is *reported*, not omitted. + */ +export const DIALECT_CELLS: readonly DialectCell[] = [ + { + id: 'sqlite', + label: 'sqlite', + env: null, + available: true, + live: false, + hasLegacyStorageForm: true, + config: () => ({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), + }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + url: PG_URL, + available: !!PG_URL, + live: true, + hasLegacyStorageForm: false, + config: () => ({ client: 'pg', connection: PG_URL }), + }, + { + id: 'mysql', + label: 'live mysql', + env: 'OS_TEST_MYSQL_URL', + url: MYSQL_URL, + available: !!MYSQL_URL, + live: true, + hasLegacyStorageForm: false, + config: () => ({ client: 'mysql2', connection: MYSQL_URL }), + }, +] as const; + +/** The live cells only — the ones the server-timezone axis applies to. */ +export const LIVE_DIALECT_CELLS = DIALECT_CELLS.filter((c) => c.live); + +/** What a server reports about its own timezone. */ +export interface ServerZone { + /** The dialect's own spelling: `Asia/Shanghai`, `+08:00`, `SYSTEM`, … */ + setting: string; + /** + * Minutes east of UTC the server is currently at, or `NaN` when the dialect + * could not be made to say. `NaN` fails the skew guard on purpose: a zone we + * cannot compare is a zone we cannot prove is skewed. + */ + offsetMinutes: number; +} + +/** Unwrap a raw result across knex's three dialect shapes. */ +function rowsOf(res: any): any[] { + if (Array.isArray(res) && Array.isArray(res[0])) return res[0]; // mysql2: [rows, fields] + if (Array.isArray(res)) return res; // better-sqlite3 + return res?.rows ?? []; // pg +} + +/** + * Read the SERVER's timezone through an already-connected driver. + * + * Both queries deliberately read the server's own setting rather than anything + * the driver configured: `driver-sql` pins the mysql2 *session* to UTC (#3942) + * and Postgres reads back whatever `TimeZone` the server was started with, so + * asking the session would report the fix instead of the hazard the fix exists + * for. + */ +export async function readServerZone(cell: DialectCell, driver: SqlDriver): Promise { + if (cell.id === 'pg') { + const rows = rowsOf( + await driver.execute( + `select current_setting('TimeZone') as tz, extract(timezone from now())::int as off_seconds`, + ), + ); + const row = rows[0] ?? {}; + return { setting: String(row.tz ?? ''), offsetMinutes: eastOfUtc(Number(row.off_seconds) / 60) }; + } + if (cell.id === 'mysql') { + // `convert_tz` resolves a numeric `+08:00` zone without the (usually + // unloaded) mysql tz tables; a NAMED global zone yields NULL there, so fall + // back to parsing the setting and let the guard fail if neither can answer. + const rows = rowsOf( + await driver.execute( + `select @@global.time_zone as tz, + timestampdiff(second, utc_timestamp(), + convert_tz(utc_timestamp(), '+00:00', @@global.time_zone)) as off_seconds`, + ), + ); + const row = rows[0] ?? {}; + const setting = String(row.tz ?? ''); + const seconds = row.off_seconds == null ? Number.NaN : Number(row.off_seconds); + return { + setting, + offsetMinutes: eastOfUtc( + Number.isFinite(seconds) ? seconds / 60 : parseUtcOffsetMinutes(setting), + ), + }; + } + // SQLite is in-process: there is no server, and therefore no server zone. + return { setting: '', offsetMinutes: Number.NaN }; +} + +/** + * Collapse `-0` onto `+0`. + * + * Not cosmetic: `expect(x).not.toBe(0)` is `Object.is`, and `Object.is(-0, 0)` + * is FALSE — so a UTC zone that arrives as `-0` (which is what negating a + * zero `getTimezoneOffset()` produces) sails through the "not UTC" guard. This + * was measured by sabotage: `TZ=UTC` passed the guard until this existed. + */ +const eastOfUtc = (minutes: number): number => (minutes === 0 ? 0 : minutes); + +/** `+08:00` / `-05:30` → minutes east of UTC; anything else → `NaN`. */ +function parseUtcOffsetMinutes(setting: string): number { + const m = /^([+-])(\d{1,2}):(\d{2})$/.exec(setting.trim()); + if (!m) return Number.NaN; + const minutes = Number(m[2]) * 60 + Number(m[3]); + return eastOfUtc(m[1] === '-' ? -minutes : minutes); +} + +/** The Node process's timezone, as the two facts the guard compares. */ +export function processZone(): { name: string; offsetMinutes: number } { + return { + name: Intl.DateTimeFormat().resolvedOptions().timeZone || '(unknown)', + // `getTimezoneOffset` is minutes WEST of UTC; flip it so both sides of the + // comparison are "minutes east", the sign every server reports. Subtracting + // rather than negating keeps a UTC process at `+0` — see {@link eastOfUtc}. + offsetMinutes: eastOfUtc(0 - new Date().getTimezoneOffset()), + }; +} + +/** + * The non-vacuity guard: server ≠ UTC ≠ process, and server ≠ process. + * + * Call it from an `it()` so a mis-provisioned run is a named red rather than a + * green nobody reads. Every failure message carries the command that fixes it, + * because the usual cause is a local run that simply never set `TZ`. + */ +export function assertThreeWayZoneSkew(cell: DialectCell, server: ServerZone): void { + const proc = processZone(); + const seen = `server=${cell.id}:${server.setting || '(unreported)'} (${server.offsetMinutes} min), ` + + `process=${proc.name} (${proc.offsetMinutes} min)`; + + expect( + Number.isFinite(server.offsetMinutes), + `could not determine the ${cell.label} server's UTC offset (${seen}) — point it at a server ` + + `with an explicit non-UTC timezone (PG: timezone=Asia/Shanghai, MySQL: default_time_zone='+08:00')`, + ).toBe(true); + + expect( + server.offsetMinutes, + `the ${cell.label} server runs at UTC (${seen}) — on UTC the D-B2 divergence is invisible and ` + + `this cell proves nothing; start it with timezone=Asia/Shanghai / default_time_zone='+08:00'`, + ).not.toBe(0); + + expect( + proc.offsetMinutes, + `the process runs at UTC (${seen}) — re-run with a skewed zone, e.g. TZ=America/New_York, ` + + `so a process-zone leak cannot hide behind an agreeing server`, + ).not.toBe(0); + + expect( + server.offsetMinutes, + `the ${cell.label} server and the process share one UTC offset (${seen}) — the two zones must ` + + `disagree, or a value folded through the wrong one still lands on the right answer`, + ).not.toBe(proc.offsetMinutes); +} 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 c54c2e9239..33dfef1fe2 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 @@ -28,10 +28,41 @@ * 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 - * `TZ=America/New_York` against servers on `Asia/Shanghai` — against real - * Postgres and MySQL too, with no workflow change needed. + * # The DRIVER and SERVER-TIMEZONE axes (#4245) + * + * D-A3 declares the matrix over `driver {SQLite, Postgres at minimum}`, and + * D-B3 added a server-timezone axis after D-B2 measured a dialect-divergent ROW + * RESULT: a bare `YYYY-MM-DD` comparand meant midnight in the SERVER's timezone, + * so the identical query over the identical instant put a row on a different + * calendar day on PG 16 @ `Asia/Shanghai` than it did on SQLite. Every sweep + * above used to hard-code `client: 'better-sqlite3'`, so neither axis existed + * here: the file's head note claimed live PG and MySQL coverage that its four + * drivers could not deliver, and the only thing holding the two dialects to the + * consensus row sets was three suites asserting their own storage forms. + * + * So every sweep now runs once per cell of `DIALECT_CELLS` — SQLite always, + * live Postgres and MySQL when `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are + * provisioned — over the SAME `TEMPORAL_CASES` / `TEMPORAL_TIME_CASES`, + * asserting the SAME `expected` row-id sets cell for cell. A dialect whose + * comparand or storage path drifts from the consensus now has a named red to + * fail, which is the signal #3773 / #3994 / #4047 each lacked. + * + * The `Temporal Conformance (live PG + MySQL)` CI job provisions both servers + * on `Asia/Shanghai` / `+08:00`, runs this package under + * `TZ=America/New_York`, and sets `OS_EXPECT_LIVE_DIALECT_MATRIX=1` so a lost + * URL is a red rather than a silent return to SQLite-only coverage. Each live + * cell additionally asserts the three-way zone skew it depends on + * (`assertThreeWayZoneSkew`): server ≠ UTC, process ≠ UTC, server ≠ process. On + * a UTC server or a UTC process the identical answers this file reports are + * answers no timezone could have perturbed. + * + * The legacy-storage sweeps (3 and 4) run only where a pre-canonical storage + * form can physically exist, which is SQLite — Postgres and MySQL keep a real + * `timestamptz` / `DATETIME(3)` / `TIME(3)`, so their rows are already one shape + * (`backfillCanonicalDatetimes` says exactly this and returns early). That is + * asserted per cell against the driver's own `needsLegacy*Repair` rule rather + * than assumed, so the day a dialect grows a repair path the missing grid says + * so out loud. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -45,191 +76,327 @@ import { import { resolveFilterTokens } from '@objectstack/core'; import { SqlDriver } from '../src/index.js'; import { LegacyStorageDriver } from './legacy-datetime-storage.testkit.js'; +import { + DIALECT_CELLS, + EXPECT_LIVE_DIALECTS, + assertThreeWayZoneSkew, + readServerZone, + type DialectCell, + type ServerZone, +} from './live-dialect-matrix.testkit.js'; const resolveTokens = (filter: T): T => resolveFilterTokens(filter, { now: new Date(TEMPORAL_NOW) }); -const CONFORMANCE_OBJECT = { - name: 'conformance', +/** + * One table per sweep, issue-prefixed: the live cells share a database with + * every other suite in this package (and with each other's runs), so a name + * collision would show up as a conformance failure in whichever suite lost the + * race. + */ +const DATETIME_TABLE = 'os4245_conformance'; +const DATETIME_LEGACY_TABLE = 'os4245_conformance_legacy'; +const TIME_TABLE = 'os4245_time_conformance'; +const TIME_LEGACY_TABLE = 'os4245_time_conformance_legacy'; + +const datetimeShape = (name: string) => ({ + name, fields: { at: { type: 'datetime' }, on: { type: 'date' }, why: { type: 'string' }, }, -}; +}); -describe('sql-driver — temporal conformance', () => { - let driver: SqlDriver; +const timeShape = (name: string) => ({ + name, + fields: { at: { type: 'time' }, why: { type: 'string' } }, +}); - beforeAll(async () => { - driver = new SqlDriver({ - client: 'better-sqlite3', - connection: { filename: ':memory:' }, - useNullAsDefault: true, - }); - // The declaration is what makes this the typed half — `at` is an instant, - // `on` a calendar day, and the driver's coercion follows from that. - await driver.initObjects([CONFORMANCE_OBJECT]); - for (const r of TEMPORAL_ROWS) { - await driver.create( - 'conformance', - { - id: r.id, - // The writer-form axis (D-E4): both writer populations must - // converge to the one canonical storage form on write. - at: r.writerForm === 'native' ? new Date(r.at) : r.at, - on: r.on, - why: r.why, - }, - { bypassTenantAudit: true } as any, - ); - } - }); +/** Row ids a filter reaches, sorted — the one thing every cell must agree on. */ +async function matchedIds(driver: SqlDriver, table: string, where: unknown): Promise { + const rows = await driver.find(table, { object: table, where } as any); + return (rows as any[]).map((r) => r.id).sort(); +} - afterAll(async () => { - await driver.disconnect?.(); - }); +/** Live cells reuse one database, so every sweep starts from a dropped table. */ +async function freshTable(driver: SqlDriver, shape: { name: string }): Promise { + await driver.execute(`drop table if exists ${shape.name}`).catch(() => {}); + await driver.initObjects([shape as any]); +} - for (const c of TEMPORAL_CASES) { - it(c.name, async () => { - const rows = await driver.find('conformance', { object: 'conformance', where: c.filter }); - const got = (rows as any[]).map((r) => r.id).sort(); - expect(got, c.note).toEqual([...c.expected].sort()); - }); +async function dropTable(driver: SqlDriver | undefined, table: string): Promise { + await driver?.execute(`drop table if exists ${table}`).catch(() => {}); +} - if (c.tokenFilter) { - it(`${c.name} — via relative tokens`, async () => { - const rows = await driver.find('conformance', { object: 'conformance', where: resolveTokens(c.tokenFilter) }); - const got = (rows as any[]).map((r) => r.id).sort(); - expect(got, c.note).toEqual([...c.expected].sort()); - }); - } - } -}); +// ── The driver axis ───────────────────────────────────────────────────────── -describe('sql-driver — temporal conformance on un-backfilled legacy storage', () => { - let driver: LegacyStorageDriver; +for (const cell of DIALECT_CELLS) { + if (!cell.available) { + declareUnprovisionedCell(cell); + continue; + } + if (cell.live) declareServerTimezoneAxis(cell); + declareDatetimeSweep(cell); + declareLegacyDatetimeSweep(cell); + declareTimeSweep(cell); + declareLegacyTimeSweep(cell); +} - beforeAll(async () => { - driver = new LegacyStorageDriver({ - client: 'better-sqlite3', - connection: { filename: ':memory:' }, - useNullAsDefault: true, - }); - await driver.initObjects([CONFORMANCE_OBJECT]); - // The two pre-#3912 storage forms, split by the same writer-form tag: - // `native` writes landed as INTEGER epoch ms (a bound JS Date), `wire` - // writes as zone-naive TEXT (CURRENT_TIMESTAMP / REST payloads). One - // column, both forms — the read-repair path must answer the same table - // the canonical sweep does. (`on` is unaffected: bare-day text has been - // the date canon since Phase 1.) - 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, - })), +/** + * A cell nobody provisioned is REPORTED, not omitted: a named skip locally, a + * failure under `OS_EXPECT_LIVE_DIALECT_MATRIX=1` — which is what stops the CI + * job from quietly degrading to SQLite-only coverage if its `env:` block is + * ever dropped. + */ +function declareUnprovisionedCell(cell: DialectCell): void { + describe(`sql-driver — temporal conformance matrix (${cell.label})`, () => { + it.skipIf(!EXPECT_LIVE_DIALECTS)( + `is provisioned — set ${cell.env} to run this cell of the D-A3 driver axis`, + () => { + expect.fail( + `${cell.env} is unset while OS_EXPECT_LIVE_DIALECT_MATRIX=1: this runner declared it ` + + `provisions live Postgres and MySQL, so the ${cell.label} cell of the temporal ` + + `conformance matrix must not be skipped (ADR-0053 D-A3 "Postgres at minimum").`, + ); + }, ); }); +} - afterAll(async () => { - await driver.disconnect?.(); - }); +/** + * The server-timezone axis (D-B3), asserted rather than assumed: three clocks — + * the server's, the process's, and UTC — must be pairwise different, or every + * sweep below could pass with the timezone code deleted. + */ +function declareServerTimezoneAxis(cell: DialectCell): void { + describe(`sql-driver — temporal conformance server-timezone axis (${cell.label})`, () => { + let zone: ServerZone; - // 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', { object: 'conformance', where: c.filter }); - const got = (rows as any[]).map((r) => r.id).sort(); - expect(got, c.note).toEqual([...c.expected].sort()); + beforeAll(async () => { + const probe = new SqlDriver(cell.config()); + try { + zone = await readServerZone(cell, probe); + } finally { + await probe.disconnect?.(); + } }); - } -}); -describe('sql-driver — Field.time conformance', () => { - let driver: SqlDriver; + it('the server, the process and UTC are three different timezones', () => { + assertThreeWayZoneSkew(cell, zone); + }); + }); +} + +// ── Sweep 1 + 2: canonical storage, literal and token spellings ───────────── + +function declareDatetimeSweep(cell: DialectCell): void { + describe(`sql-driver — temporal conformance (${cell.label})`, () => { + let driver: SqlDriver; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + // The declaration is what makes this the typed half — `at` is an instant, + // `on` a calendar day, and the driver's coercion follows from that. + await freshTable(driver, datetimeShape(DATETIME_TABLE)); + for (const r of TEMPORAL_ROWS) { + await driver.create( + DATETIME_TABLE, + { + id: r.id, + // The writer-form axis (D-E4): both writer populations must + // converge to the one canonical storage form on write. + at: r.writerForm === 'native' ? new Date(r.at) : r.at, + on: r.on, + why: r.why, + }, + { bypassTenantAudit: true } as any, + ); + } + }); - beforeAll(async () => { - driver = new SqlDriver({ - client: 'better-sqlite3', - connection: { filename: ':memory:' }, - useNullAsDefault: true, + afterAll(async () => { + await dropTable(driver, DATETIME_TABLE); + await driver?.disconnect?.(); }); - await driver.initObjects([ - { name: 'time_conformance', fields: { at: { type: 'time' }, why: { type: 'string' } } }, - ]); - for (const r of TEMPORAL_TIME_ROWS) { - await driver.create( - 'time_conformance', - { + + for (const c of TEMPORAL_CASES) { + it(c.name, async () => { + expect(await matchedIds(driver, DATETIME_TABLE, c.filter), c.note).toEqual( + [...c.expected].sort(), + ); + }); + + if (c.tokenFilter) { + it(`${c.name} — via relative tokens`, async () => { + expect( + await matchedIds(driver, DATETIME_TABLE, resolveTokens(c.tokenFilter)), + c.note, + ).toEqual([...c.expected].sort()); + }); + } + } + }); +} + +// ── Sweep 3: un-backfilled legacy storage ─────────────────────────────────── + +function declareLegacyDatetimeSweep(cell: DialectCell): void { + describe(`sql-driver — temporal conformance on un-backfilled legacy storage (${cell.label})`, () => { + let driver: LegacyStorageDriver; + + beforeAll(async () => { + driver = new LegacyStorageDriver(cell.config()); + await freshTable(driver, datetimeShape(DATETIME_LEGACY_TABLE)); + if (!cell.hasLegacyStorageForm) { + // Nothing to seed — but the marker still has to go, or the probe below + // would report "already canonical" and prove nothing about the dialect. + driver.forgetCanonical(DATETIME_LEGACY_TABLE, 'at'); + return; + } + // The two pre-#3912 storage forms, split by the same writer-form tag: + // `native` writes landed as INTEGER epoch ms (a bound JS Date), `wire` + // writes as zone-naive TEXT (CURRENT_TIMESTAMP / REST payloads). One + // column, both forms — the read-repair path must answer the same table + // the canonical sweep does. (`on` is unaffected: bare-day text has been + // the date canon since Phase 1.) + await driver.seedLegacyRows( + DATETIME_LEGACY_TABLE, + 'at', + TEMPORAL_ROWS.map((r) => ({ id: r.id, - // The mixed-writer axis for wall clocks (#3994 measured this exact - // column): a bound `Date` and a canonical-text write must converge. - at: r.writerForm === 'native' ? new Date(`1970-01-01T${r.at}Z`) : r.at, + at: r.writerForm === 'native' ? Date.parse(r.at) : r.at.replace('T', ' ').replace('Z', ''), + on: r.on, why: r.why, - }, - { bypassTenantAudit: true } as any, + })), ); + }); + + afterAll(async () => { + await dropTable(driver, DATETIME_LEGACY_TABLE); + await driver?.disconnect?.(); + }); + + // The cell list says this grid exists on SQLite only; the driver's own rule + // is what decides. Asserting them equal is what keeps "PG has no legacy + // storage form" an enforced claim instead of a comment — and turns the day + // a dialect grows a repair path into a red that names the missing grid. + it('agrees with the driver about whether a pre-canonical storage form can exist here', () => { + expect( + driver.legacyDatetimeRepairApplies(DATETIME_LEGACY_TABLE, 'at'), + cell.hasLegacyStorageForm + ? 'this cell claims a legacy datetime storage form but the driver applies no read repair' + : `${cell.label} grew a legacy datetime repair path — it now needs the legacy sweep too, ` + + `so flip hasLegacyStorageForm and seed the dialect's raw pre-canonical forms`, + ).toBe(cell.hasLegacyStorageForm); + }); + + // 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. + if (cell.hasLegacyStorageForm) { + for (const c of TEMPORAL_CASES) { + it(c.name, async () => { + expect(await matchedIds(driver, DATETIME_LEGACY_TABLE, c.filter), c.note).toEqual( + [...c.expected].sort(), + ); + }); + } } }); +} - afterAll(async () => { - await driver.disconnect?.(); - }); +// ── Sweep 4: Field.time, canonical and legacy ─────────────────────────────── - for (const c of TEMPORAL_TIME_CASES) { - it(c.name, async () => { - const rows = await driver.find('time_conformance', { object: 'time_conformance', where: c.filter }); - const got = (rows as any[]).map((r) => r.id).sort(); - expect(got, c.note).toEqual([...c.expected].sort()); - }); - } -}); +function declareTimeSweep(cell: DialectCell): void { + describe(`sql-driver — Field.time conformance (${cell.label})`, () => { + let driver: SqlDriver; -describe('sql-driver — Field.time conformance on un-backfilled legacy storage', () => { - let driver: LegacyStorageDriver; + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await freshTable(driver, timeShape(TIME_TABLE)); + for (const r of TEMPORAL_TIME_ROWS) { + await driver.create( + TIME_TABLE, + { + id: r.id, + // The mixed-writer axis for wall clocks (#3994 measured this exact + // column): a bound `Date` and a canonical-text write must converge. + at: r.writerForm === 'native' ? new Date(`1970-01-01T${r.at}Z`) : r.at, + why: r.why, + }, + { bypassTenantAudit: true } as any, + ); + } + }); - beforeAll(async () => { - driver = new LegacyStorageDriver({ - client: 'better-sqlite3', - connection: { filename: ':memory:' }, - useNullAsDefault: true, + afterAll(async () => { + await dropTable(driver, TIME_TABLE); + await driver?.disconnect?.(); }); - 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 () => { + expect(await matchedIds(driver, TIME_TABLE, c.filter), c.note).toEqual( + [...c.expected].sort(), + ); + }); + } }); +} + +function declareLegacyTimeSweep(cell: DialectCell): void { + describe(`sql-driver — Field.time conformance on un-backfilled legacy storage (${cell.label})`, () => { + let driver: LegacyStorageDriver; + + beforeAll(async () => { + driver = new LegacyStorageDriver(cell.config()); + await freshTable(driver, timeShape(TIME_LEGACY_TABLE)); + if (!cell.hasLegacyStorageForm) { + driver.forgetCanonicalTime(TIME_LEGACY_TABLE, 'at'); + return; + } + // 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_LEGACY_TABLE, + '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, + })), + ); + }); - for (const c of TEMPORAL_TIME_CASES) { - it(c.name, async () => { - const rows = await driver.find('time_conformance', { object: 'time_conformance', where: c.filter }); - const got = (rows as any[]).map((r) => r.id).sort(); - expect(got, c.note).toEqual([...c.expected].sort()); + afterAll(async () => { + await dropTable(driver, TIME_LEGACY_TABLE); + await driver?.disconnect?.(); }); - } -}); + + it('agrees with the driver about whether a pre-canonical storage form can exist here', () => { + expect( + driver.legacyTimeRepairApplies(TIME_LEGACY_TABLE, 'at'), + cell.hasLegacyStorageForm + ? 'this cell claims a legacy time storage form but the driver applies no read repair' + : `${cell.label} grew a legacy time repair path — it now needs the legacy sweep too, ` + + `so flip hasLegacyStorageForm and seed the dialect's raw pre-canonical forms`, + ).toBe(cell.hasLegacyStorageForm); + }); + + if (cell.hasLegacyStorageForm) { + for (const c of TEMPORAL_TIME_CASES) { + it(c.name, async () => { + expect(await matchedIds(driver, TIME_LEGACY_TABLE, c.filter), c.note).toEqual( + [...c.expected].sort(), + ); + }); + } + } + }); +}