Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/temporal-storage-form-axis-tests.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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());
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +33,29 @@ import { SqliteWasmDriver } from './index.js';
const resolveTokens = <T,>(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<Record<string, unknown>>): Promise<void> {
await this.knex(table).insert(rows);
this.canonicalDatetimeFields[table]?.delete(field);
}

async seedLegacyTimeRows(table: string, field: string, rows: Array<Record<string, unknown>>): Promise<void> {
await this.knex(table).insert(rows);
this.canonicalTimeFields[table]?.delete(field);
}

async destroy(): Promise<void> {
await this.knex.destroy();
}
}

describe('driver-sqlite-wasm — temporal conformance', () => {
let driver: SqliteWasmDriver;

Expand Down Expand Up @@ -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());
});
}
});
38 changes: 38 additions & 0 deletions packages/spec/src/data/temporal-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down
Loading