Skip to content

Commit d257234

Browse files
os-support-aiclaude
andcommitted
test(driver-sql): live-dialect arm for the window door, and the full FROM/TO table
Contract-review patch round on PR #16716 (findings F2, F3, F5 and the non-governed half of F4). No production code changes. F5 — merged origin/main, so this branch now carries #16619: `formatOutput`'s instant gates are unconditional, which is the presenter this door actually ships through. Every CI leg on the previous head measured the pre-B1 presenter. F3 — `sql-driver-window-function-output.test.ts` gains a `measure(cell)` arm over `DIALECT_CELLS`, declared through `declareDialectCell` so an unprovisioned cell is a NAMED SKIP and never a silent pass. It asserts the two halves the SQLite-only arm cannot: `typeof row.ok === 'boolean'` (the MySQL half of the `isSqlite || isMysql` boolean gate) and the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` text for `closed_at` / `created_at` / `updated_at` (the PG + MySQL instant fold). SS4 reads the same row back through raw knex to prove the fold is the driver's and not the client's. F2 — the changeset gains a per-class, per-dialect FROM/TO table covering all seven classes this door moves: adds `external.columnMap` (remote column key -> local field key, every dialect), the SQLite numeric-string -> `number` move, the MySQL `Field.date` `Date` -> `YYYY-MM-DD` move and `Field.time` -> canonical `HH:MM:SS[.fff]`, and spells the instant TO as the canonical text on every dialect. `minor`, the BREAKING banner and the ADR-0087 disposition are unchanged. F4 (non-governed half) — the header comment of `sql-driver-13973-canonical-iso-read-door.test.ts` said this door applies no read presentation. It routes through `formatOutput` since #16609, so the comment now says that and flags that ADR-0053 D-F1 still records it as not covered, with governed docs-only card #16782 carrying the amendment. `docs/adr/**` is untouched here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH
1 parent 40b7cd4 commit d257234

3 files changed

Lines changed: 223 additions & 5 deletions

File tree

.changeset/window-functions-row-presentation.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,44 @@ other read door no longer produces — so on the live dialects the divergence wa
3232
between this door and the driver's own declared read contract, not merely
3333
between dialects.
3434

35+
**What moves, FROM → TO, per column class and per dialect.** Routing this door
36+
through `formatOutput` moves SEVEN classes, not only the boolean and JSON ones
37+
the defect was reported as. `unchanged` means the storage form on that dialect
38+
already WAS the presented form, so the row is byte-identical there — it is
39+
recorded rather than omitted, because the same code path now runs for it.
40+
41+
| class | sqlite | postgres | mysql |
42+
|---|---|---|---|
43+
| `Field.boolean` | `1` / `0``true` / `false` | unchanged (native `boolean`) | `1` / `0``true` / `false` |
44+
| `Field.object` (JSON) | `'{"k":1}'` TEXT → `{ k: 1 }` | unchanged (native `jsonb`) | unchanged (mysql2 parses JSON) |
45+
| numeric fields | `'4'``4` (a numeric STRING off a legacy TEXT-affinity column) | unchanged | unchanged |
46+
| `Field.datetime` + `created_at` / `updated_at` | unchanged — already the canonical text since #3912; a legacy zone-naive row is repaired to it | `Date``'2026-01-10T09:00:00.123Z'` | `Date``'2026-01-10T09:00:00.123Z'` |
47+
| `Field.date` | unchanged (`toDateOnly` on text is identity) | unchanged (the driver pins the `date` OID parser to text) | `Date``'2026-01-10'` |
48+
| `Field.time` | unchanged | `'09:30:00.5'``'09:30:00.500'` | → canonical `HH:MM:SS[.fff]` |
49+
| `external.columnMap` | the row KEY renames: remote column key → local field key | same | same |
50+
51+
The instant TO is the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` TEXT **on every
52+
dialect**, never a JS `Date` — that is ADR-0053 D-F1 as #16619 landed it, and
53+
this door now runs the same presenter, so it answers the same shape the other
54+
read doors do.
55+
56+
`external.columnMap` is the one class nobody named on the card, and it is a KEY
57+
move rather than a value move: on an external object with a `columnMap`, the row
58+
this door returns is now keyed by the LOCAL field names, as `find()` has always
59+
keyed it, instead of by the remote physical column names.
60+
3561
**What to do.** Code that compensated for the storage forms stops being
3662
correct and should simply drop the compensation:
3763

3864
- `if (row.ok === 1)``if (row.ok)`; the value is a real boolean now.
3965
- `JSON.parse(row.meta)``row.meta`; it is already the parsed value, and
4066
parsing an object throws.
67+
- `Number(row.amount)``row.amount`; a numeric column is a `number`.
4168
- A `Field.datetime` / `Field.date` / `Field.time` / `created_at` / `updated_at`
4269
read through this door is now the same presented value `find()` gives, so a
43-
branch that re-normalised it can go.
70+
branch that re-normalised it — or that called `Date` methods on it — can go.
71+
- A reader of an external object with a `columnMap` indexes the row by the LOCAL
72+
field key, not the remote column key.
4473

4574
**The alias columns are carved out**, which is the design question this door
4675
raised. A window alias is a computed value, not a declared field, so no declared

packages/drivers/driver-sql/src/sql-driver-13973-canonical-iso-read-door.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@
1717
* an Invalid `Date`, which has no canonical text and passes through as the
1818
* `Date` it is (pinned by `sql-driver-14078-invalid-date-materialisation.test.ts`;
1919
* never met here, because this fixture writes only valid instants).
20-
* `findWithWindowFunctions` is not one of those doors: it applies no read
21-
* presentation of any kind, D-F1 records it as not covered, and #16609 holds
22-
* it.
20+
* `findWithWindowFunctions` used to be the one read door outside this list: it
21+
* applied no read presentation of any kind. Since #16609 it routes each row
22+
* through the SAME `formatOutput` pass `find()` runs (minus the window-alias
23+
* columns), so it presents these two column classes exactly as the doors above
24+
* do — pinned by `sql-driver-window-function-output.test.ts`, whose live cells
25+
* assert the canonical instant on Postgres and MySQL for this door too.
26+
* ⚠️ ADR-0053 D-F1 still RECORDS that door as not covered; the tree is ahead of
27+
* the declaration there, and docs-only governed card #16782 carries the
28+
* amendment. Do not read the ADR line as the current behaviour.
2329
*
2430
* §A1–§A3 measure the four row doors on the fixture table; §A5–§A7 the three
2531
* write doors whose return is a row, on a second table so their writes cannot

packages/drivers/driver-sql/src/sql-driver-window-function-output.test.ts

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,38 @@
2222
*
2323
* The alias carve-out is pinned here too — see the collision block at the
2424
* bottom, which is the design question the card left to the implementer.
25+
*
26+
* ## Two arms, and why the SQLite one is not the whole file
27+
*
28+
* The first `describe` below is the SQLite agreement arm. It cannot measure two
29+
* halves of this door's contract, and says so rather than letting a green stand
30+
* in for them:
31+
*
32+
* - the BOOLEAN rule fires under `isSqlite || isMysql` (`sql-driver.ts`
33+
* `formatOutput`), so its MySQL half is invisible on SQLite;
34+
* - the INSTANT classes are stored canonically on SQLite since #3912, so
35+
* removing the read presentation moves nothing for them there — the five
36+
* instant agreements CANNOT fail on this dialect. On Postgres and MySQL the
37+
* client library hands the driver a `Date`, so the fold is real work.
38+
*
39+
* The `measure(cell)` arm at the bottom is those two halves, over `DIALECT_CELLS`
40+
* — the ADR-0053 D-A3 driver axis, declared through `declareDialectCell` so an
41+
* unprovisioned cell is a NAMED SKIP and never a silent pass. Locally (no
42+
* `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL`) the two live cells report as
43+
* skips and this door's MySQL boolean half and PG/MySQL instant fold are NOT
44+
* MEASURED; the `Temporal Conformance (live PG + MySQL)` job provisions both and
45+
* measures them there.
2546
*/
2647

27-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
48+
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
2849
import { SqlDriver } from '../src/index.js';
50+
import {
51+
DIALECT_CELLS,
52+
assertThreeWayZoneSkew,
53+
declareDialectCell,
54+
readServerZone,
55+
type DialectCell,
56+
} from './live-dialect-matrix.testkit.js';
2957

3058
const TABLE = 'window_row';
3159

@@ -195,3 +223,158 @@ describe('rows leaving findWithWindowFunctions() (#16609)', () => {
195223
});
196224
});
197225
});
226+
227+
// ── THE LIVE-DIALECT ARM ─────────────────────────────────────────────────────
228+
//
229+
// The two halves the SQLite arm above cannot measure, on the ADR-0053 D-A3
230+
// driver axis. Everything here is asserted ABSOLUTELY rather than as a
231+
// door-to-door agreement where the absolute shape is the point: an agreement
232+
// between two doors that are both wrong is green, and the MySQL boolean half
233+
// and the PG/MySQL instant fold are exactly the places this door had never been
234+
// measured at all.
235+
236+
const LIVE_TABLE = 'os16609_window_row';
237+
238+
/** The canonical instant text — the ONE shape every read door presents. */
239+
const LIVE_ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
240+
241+
/** The instant classes this door now folds: the audit stamps + a `Field.datetime`. */
242+
const LIVE_INSTANT_COLUMNS = ['closed_at', 'created_at', 'updated_at'] as const;
243+
244+
const LIVE_CLOSED_AT = ['2026-01-10T09:00:00.123Z', '2026-02-14T21:30:45.678Z'] as const;
245+
246+
/** Spelled once, the same assertion `sql-driver-13973-…` makes for the other doors. */
247+
function expectCanonicalInstant(value: unknown, label: string): void {
248+
expect(value, `${label}: the window door did not return the column`).toBeDefined();
249+
expect(value, `${label}: null`).not.toBeNull();
250+
expect(
251+
value instanceof Date,
252+
`${label}: findWithWindowFunctions handed out a JS Date (${String(value)}) — ADR-0053 D-F1 ` +
253+
`rules the canonical text on every dialect, and this door runs the same formatOutput pass`,
254+
).toBe(false);
255+
expect(typeof value, `${label}: type`).toBe('string');
256+
expect(value, `${label}: shape`).toMatch(LIVE_ISO_Z);
257+
}
258+
259+
function measure(cell: DialectCell): void {
260+
describe(`#16609 — findWithWindowFunctions presents on every dialect (${cell.label})`, () => {
261+
let driver: SqlDriver;
262+
let windowed: any[] = [];
263+
let found: any[] = [];
264+
265+
beforeAll(async () => {
266+
driver = new SqlDriver(cell.config());
267+
// A live cell proves nothing unless server, process and UTC disagree —
268+
// the same guard every other matrix consumer runs.
269+
if (cell.live) assertThreeWayZoneSkew(cell, await readServerZone(cell, driver));
270+
await driver.execute(`drop table if exists ${LIVE_TABLE}`).catch(() => {});
271+
await driver.initObjects([{ name: LIVE_TABLE, fields: FIELDS as any }] as any);
272+
for (const [i, iso] of LIVE_CLOSED_AT.entries()) {
273+
await driver.create(
274+
LIVE_TABLE,
275+
{
276+
id: `w${i}`,
277+
// Row 0 declares `true`, row 1 `false` — a rule that folded every
278+
// value one way could not pass both.
279+
ok: i === 0,
280+
meta: { k: i },
281+
amount: 10 + i,
282+
region: i === 0 ? 'east' : 'west',
283+
// Bound as a JS `Date`, which is the shape whose fold this measures.
284+
closed_at: new Date(iso),
285+
closed_on: '2026-01-10',
286+
starts_at: '09:30:00.500',
287+
},
288+
{ bypassTenantAudit: true },
289+
);
290+
}
291+
const query = { windowFunctions: [ROW_NUMBER], orderBy: [{ field: 'id', order: 'asc' as const }] };
292+
windowed = await driver.findWithWindowFunctions(LIVE_TABLE, query as any, { bypassTenantAudit: true });
293+
found = await driver.find(
294+
LIVE_TABLE,
295+
{ orderBy: [{ field: 'id', order: 'asc' }] },
296+
{ bypassTenantAudit: true },
297+
);
298+
}, 60_000);
299+
300+
afterAll(async () => {
301+
await driver?.execute(`drop table if exists ${LIVE_TABLE}`).catch(() => {});
302+
await driver?.disconnect();
303+
});
304+
305+
it('§L0 the fixture is non-vacuous: the window door returned both rows carrying every column under test', () => {
306+
// Every assertion below reads these keys off these rows; a door that did
307+
// not select a column would let them all pass having checked nothing.
308+
expect(windowed).toHaveLength(LIVE_CLOSED_AT.length);
309+
for (const row of windowed) {
310+
for (const col of [...LIVE_INSTANT_COLUMNS, 'ok', 'rn'] as const) {
311+
expect(row[col], `${row.id}.${col} missing from the window row`).toBeDefined();
312+
expect(row[col], `${row.id}.${col} is null`).not.toBeNull();
313+
}
314+
}
315+
});
316+
317+
it('§L1 a declared Field.boolean answers a boolean, not 1/0', () => {
318+
// The MySQL half of the `isSqlite || isMysql` gate in `formatOutput` —
319+
// `tinyint(1)`, which mysql2 hands back as a JS number. Unmeasurable on
320+
// the SQLite-only arm above, and the reason this cell exists.
321+
for (const row of windowed) {
322+
expect(typeof row.ok, `${row.id}.ok on ${cell.label}`).toBe('boolean');
323+
}
324+
expect(windowed.map((r) => r.ok)).toEqual([true, false]);
325+
});
326+
327+
it('§L2 the instant classes are canonical ISO-Z text, never a Date', () => {
328+
// ADR-0053 D-F1 through this door: the audit stamps and every declared
329+
// `Field.datetime`. On PG/MySQL the client hands the driver a `Date`, so
330+
// this is the fold doing real work — see §L4.
331+
for (const row of windowed) {
332+
for (const col of LIVE_INSTANT_COLUMNS) expectCanonicalInstant(row[col], `${row.id}.${col}`);
333+
}
334+
expect(windowed.map((r) => r.closed_at)).toEqual([...LIVE_CLOSED_AT]);
335+
});
336+
337+
it('§L3 the window row equals the find() row on every declared column', () => {
338+
expect(found).toHaveLength(windowed.length);
339+
for (let i = 0; i < found.length; i++) {
340+
for (const column of DECLARED_COLUMNS) {
341+
expect(typeof windowed[i][column], `typeof ${column} on row ${i} (${cell.label})`).toBe(
342+
typeof found[i][column],
343+
);
344+
expect(windowed[i][column], `${column} on row ${i} (${cell.label})`).toEqual(found[i][column]);
345+
}
346+
}
347+
});
348+
349+
it("§L4 the fold is the driver's, not the client's: raw knex still materialises the dialect's own shape", async () => {
350+
const raw: any = await (driver as any).knex(LIVE_TABLE).where('id', 'w0').first();
351+
expect(raw, 'raw read returned nothing').toBeTruthy();
352+
if (cell.live) {
353+
// Postgres (`timestamptz`) and MySQL (`DATETIME(3)`) hand a `Date` to
354+
// the driver — D-F2: the client parser is untouched, the driver folds at
355+
// its own read boundary. This is what makes §L2 a measurement rather
356+
// than a restatement of the client's behaviour.
357+
for (const col of LIVE_INSTANT_COLUMNS) {
358+
expect(
359+
raw[col] instanceof Date,
360+
`${cell.label} raw ${col} is ${typeof raw[col]} (${String(raw[col])}) — the client ` +
361+
`parser was changed, which the #13973 ruling forbids`,
362+
).toBe(true);
363+
}
364+
expect((raw.closed_at as Date).toISOString()).toBe(windowed[0].closed_at);
365+
}
366+
if (cell.id === 'mysql') {
367+
// Same for the boolean: `tinyint(1)` off the raw client is a number, so
368+
// §L1's `boolean` on this cell was produced by `formatOutput`.
369+
expect(typeof raw.ok, 'mysql raw ok').toBe('number');
370+
}
371+
});
372+
});
373+
}
374+
375+
// A matrix that silently finds zero cells reports OK — every cell is declared
376+
// EITHER WAY, measured when it is provisioned and a NAMED SKIP when it is not
377+
// (a named RED under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`).
378+
for (const cell of DIALECT_CELLS) {
379+
declareDialectCell(cell, 'window-function row presentation (#16609)', measure);
380+
}

0 commit comments

Comments
 (0)