From 6282fd3bd5080a60aa4a33909594c8eb87f42b45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 00:10:42 +0000 Subject: [PATCH 1/3] fix(driver-sql)!: present findWithWindowFunctions rows like every other read door (#16609) The one record read door that returned `await builder` with no presentation: no `formatOutput` (every `find()`/`findOne()` row gets it) and no `presentReadValue` (`aggregate()`/`distinct()` got it under #3797/#3849). So a declared `Field.boolean` answered `1` where `find()` answered `true`, and a declared `Field.object` answered the stored JSON text where `find()` answered the parsed object. Each row now runs through the same `formatOutput` pass, minus the window function alias columns, which are computed values rather than declared fields. The collision case is ruled and pinned: an alias spelled the same as a declared field already won the key in SQL (`select *` plus ` as ok` keeps the last column), and its value now stays raw rather than being folded through the declared type's rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../window-functions-row-presentation.md | 55 +++++ .../sql-driver-window-function-output.test.ts | 197 ++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 58 +++++- 3 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 .changeset/window-functions-row-presentation.md create mode 100644 packages/drivers/driver-sql/src/sql-driver-window-function-output.test.ts diff --git a/.changeset/window-functions-row-presentation.md b/.changeset/window-functions-row-presentation.md new file mode 100644 index 0000000000..2da0c933a0 --- /dev/null +++ b/.changeset/window-functions-row-presentation.md @@ -0,0 +1,55 @@ +--- +"@objectstack/driver-sql": minor +--- + +fix(driver-sql)!: `findWithWindowFunctions()` presents its rows like every other read door — a declared boolean answers `true`, not `1` (#16609) + + + +**BREAKING** on the rows returned by `SqlDriver#findWithWindowFunctions()`. +Shipped as `minor` under the repo's launch-window convention for breaking +changes, matching #3849 — the `aggregate()` / `distinct()` half of this same +gap, which graded `minor` for the same boolean-shape move. + +**What was wrong.** `findWithWindowFunctions()` was the one record read door +that returned `await builder` with no presentation at all: no `formatOutput` +(which every `find()` / `findOne()` row gets) and no `presentReadValue` (which +`aggregate()` / `distinct()` got under #3797 / #3849). So it handed back +STORAGE forms where every other door hands back the declared type's +presentation. Measured on SQLite against the built package, one row through the +two doors: + +``` +find(): { ok: true, closed_at: '2026-01-10T09:00:00.123Z', meta: { k: 1 } } +findWithWindowFunctions(): { ok: 1, closed_at: '2026-01-10T09:00:00.123Z', meta: '{"k":1}', rn: 1 } +``` + +A declared `Field.boolean` answered `1` where `find()` answered `true`; a +declared `Field.object` answered the stored JSON TEXT where `find()` answered +the parsed object. On Postgres and MySQL the same door handed out the client +library's `Date` for `Field.datetime` and the audit stamps — the one shape every +other read door no longer produces — so on the live dialects the divergence was +between this door and the driver's own declared read contract, not merely +between dialects. + +**What to do.** Code that compensated for the storage forms stops being +correct and should simply drop the compensation: + +- `if (row.ok === 1)` → `if (row.ok)`; the value is a real boolean now. +- `JSON.parse(row.meta)` → `row.meta`; it is already the parsed value, and + parsing an object throws. +- A `Field.datetime` / `Field.date` / `Field.time` / `created_at` / `updated_at` + read through this door is now the same presented value `find()` gives, so a + branch that re-normalised it can go. + +**The alias columns are carved out**, which is the design question this door +raised. A window alias is a computed value, not a declared field, so no declared +field's presentation rule touches it. When an alias is spelled the same as a +declared field, SQL had already decided which value wins the key — `select *` +plus ` as ok` projects two columns named `ok` and the row keeps the +LAST, so the computed value wins and the declared column's value is not in the +row at all. That is unchanged. What is now ruled is that the winning value stays +RAW: presenting a `row_number` of `1` and `2` as the declared boolean would fold +both to `true` and destroy the value the caller asked for. This is the same +ruling `aggregate()` already makes for a date-bucketed column aliased as its own +field name. diff --git a/packages/drivers/driver-sql/src/sql-driver-window-function-output.test.ts b/packages/drivers/driver-sql/src/sql-driver-window-function-output.test.ts new file mode 100644 index 0000000000..e601c4c76f --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-window-function-output.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Rows leaving `findWithWindowFunctions()` (#16609). + * + * It was the last read door that returned `await builder` with NO presentation: + * no `formatOutput` (which every `find()` / `findOne()` row gets) and no + * `presentReadValue` (which `aggregate()` / `distinct()` got under #3797 / + * #3849). So it handed back STORAGE forms where every other door hands back the + * declared type's presentation — a declared `Field.boolean` answered `1` + * instead of `true`, a declared `Field.object` answered the stored JSON TEXT + * instead of the parsed object. + * + * The contract asserted here is DOOR-TO-DOOR AGREEMENT: the same row read + * through `findWithWindowFunctions()` and through `find()` is the same row. It + * is deliberately written as an agreement rather than as absolute literals for + * the instant classes, because what `formatOutput` produces for them is itself + * under change (ADR-0053 D-F1) — and an agreement is stable whichever way that + * lands, since both doors move together. Booleans and JSON are additionally + * pinned ABSOLUTELY: they are wrong on SQLite today and that ruling does not + * touch them. + * + * The alias carve-out is pinned here too — see the collision block at the + * bottom, which is the design question the card left to the implementer. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +const TABLE = 'window_row'; + +/** Every declared kind the read presentation has a rule for. */ +const FIELDS = { + id: { type: 'text' }, + ok: { type: 'boolean' }, + meta: { type: 'object' }, + closed_at: { type: 'datetime' }, + closed_on: { type: 'date' }, + starts_at: { type: 'time' }, + amount: { type: 'number' }, + region: { type: 'string' }, +} as const; + +/** The declared columns, plus the audit stamps the driver adds itself. */ +const DECLARED_COLUMNS = [...Object.keys(FIELDS), 'created_at', 'updated_at']; + +const ROW_NUMBER = { + function: 'row_number', + alias: 'rn', + orderBy: [{ field: 'id', order: 'asc' as const }], +}; + +describe('rows leaving findWithWindowFunctions() (#16609)', () => { + let driver: SqlDriver; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + + await driver.initObjects([{ name: TABLE, fields: FIELDS as any }]); + + for (const [id, ok, meta, amount, region] of [ + ['a', true, { k: 1 }, 10, 'east'], + ['b', false, { k: 2, nested: ['x'] }, 20, 'west'], + ] as const) { + await driver.create( + TABLE, + { + id, + ok, + meta, + amount, + region, + closed_at: new Date('2026-01-10T09:00:00.123Z'), + closed_on: '2026-01-10', + starts_at: '09:30:00.500', + }, + { bypassTenantAudit: true }, + ); + } + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + const viaFind = () => + driver.find(TABLE, { orderBy: [{ field: 'id', order: 'asc' }] }, { bypassTenantAudit: true }); + + const viaWindow = (windowFunctions: any[] = [ROW_NUMBER]) => + driver.findWithWindowFunctions( + TABLE, + { windowFunctions, orderBy: [{ field: 'id', order: 'asc' }] }, + { bypassTenantAudit: true }, + ); + + describe('door-to-door agreement — every declared kind', () => { + it('answers each declared column exactly as find() answers it', async () => { + const [found, windowed] = await Promise.all([viaFind(), viaWindow()]); + expect(windowed).toHaveLength(found.length); + expect(found.length).toBeGreaterThan(0); + + for (let i = 0; i < found.length; i++) { + for (const column of DECLARED_COLUMNS) { + // Same VALUE and same TYPE. `toEqual` alone would let `1` pass for + // `true` under no coercion, but not `'{"k":1}'` for `{ k: 1 }` — so + // the type assertion is what catches the boolean half. + expect(typeof windowed[i][column], `typeof ${column} on row ${i}`).toBe( + typeof found[i][column], + ); + expect(windowed[i][column], `${column} on row ${i}`).toEqual(found[i][column]); + } + } + }); + + it('carries no declared column the other door does not', async () => { + const [found, windowed] = await Promise.all([viaFind(), viaWindow()]); + // The window door's row is the find() row plus the alias, and nothing else. + expect(Object.keys(windowed[0]).sort()).toEqual([...Object.keys(found[0]), 'rn'].sort()); + }); + }); + + describe('the absolute pins — boolean and json', () => { + // These two are wrong on SQLite today and ADR-0053 D-F1 does not touch + // them, so they are safe to assert as literals rather than as agreements. + it('presents a declared Field.boolean as a boolean, not 1/0', async () => { + const rows = await viaWindow(); + expect(rows.map((r: any) => r.ok)).toEqual([true, false]); + }); + + it('presents a declared Field.object as the parsed object, not JSON text', async () => { + const rows = await viaWindow(); + expect(rows.map((r: any) => r.meta)).toEqual([{ k: 1 }, { k: 2, nested: ['x'] }]); + }); + }); + + describe('the instant classes — asserted as agreements, never as literals', () => { + // ⛔ Deliberately NOT pinned to an absolute shape: what `formatOutput` + // produces for a datetime / date / time is under change (ADR-0053 D-F1). + // Both doors run the same pass, so they move together and this stays true. + it.each(['closed_at', 'closed_on', 'starts_at', 'created_at', 'updated_at'])( + 'agrees with find() on %s', + async (column) => { + const [found, windowed] = await Promise.all([viaFind(), viaWindow()]); + expect(windowed.map((r: any) => r[column])).toEqual(found.map((r: any) => r[column])); + // And it is a presented value, not the raw SQLite storage form: an + // un-presented `Field.datetime` written as a JS `Date` comes back an + // INTEGER epoch. This half holds whatever the presented shape becomes. + expect(windowed.every((r: any) => typeof r[column] !== 'number')).toBe(true); + }, + ); + }); + + describe('the alias columns are carved OUT of the presentation', () => { + it('leaves the computed alias value alone', async () => { + const rows = await viaWindow(); + expect(rows.map((r: any) => Number(r.rn))).toEqual([1, 2]); + }); + + // ── THE COLLISION RULING (#16609) ────────────────────────────────────── + // + // An alias may be spelled the same as a declared field. SQL decides that + // one before the driver sees it: `select *` plus ` as ok` projects + // two columns named `ok`, and the row object keeps the LAST — so the + // COMPUTED value wins the key and the declared column's value is not in the + // row at all. That was already true before #16609 and is unchanged by it. + // + // What #16609 rules is the second half: the winning value stays RAW. It is + // a computed number, so no declared field's presentation rule may touch it + // — applying the `Field.boolean` rule here would fold ROW_NUMBER 1 and 2 + // into `true` and `true` and destroy the value the caller asked for. + // + // This is the same ruling `aggregate()` already made for a date-BUCKETED + // column aliased AS its own field name ("leaves a date-BUCKETED column as + // its label, not an instant", `sql-driver-aggregate-temporal-output.test.ts`): + // a computed value landing under a declared name is still a computed value. + it('a colliding alias wins the key AND keeps its raw computed value', async () => { + const rows = await viaWindow([{ ...ROW_NUMBER, alias: 'ok' }]); + + // The declared `Field.boolean ok` is `true` then `false`. If the alias + // had lost the key we would read `[true, false]`; if it had won the key + // but been presented as the declared boolean we would read `[true, true]` + // — that pair is what makes this assertion able to fail three ways. + expect(rows.map((r: any) => r.ok)).toEqual([1, 2]); + expect(rows.every((r: any) => typeof r.ok === 'number')).toBe(true); + }); + + it('a colliding alias does not disturb the other declared columns', async () => { + const rows = await viaWindow([{ ...ROW_NUMBER, alias: 'ok' }]); + // `meta` is still presented; only the collided key is carved out. + expect(rows.map((r: any) => r.meta)).toEqual([{ k: 1 }, { k: 2, nested: ['x'] }]); + }); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 64b54958e1..057310aead 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -9003,7 +9003,63 @@ export class SqlDriver implements IDataDriver { if (query.limit !== undefined) builder.limit(query.limit); if (query.offset !== undefined) builder.offset(query.offset); - return await builder; + const rows = await builder; + if (!Array.isArray(rows)) return rows; + + // [#16609] The last read door that returned STORAGE forms. It handed back + // `await builder` with no presentation at all, while `find()`/`findOne()` + // run every row through `formatOutput` and `aggregate()`/`distinct()` got + // `presentReadValue` under #3797/#3849 — so one driver answered one + // declared column two ways depending on which door you knocked on. + // Measured on `main` at `2e6a2ea4c9`, one row through the two doors: + // find() -> { ok: true, meta: { k: 1 } } + // window -> { ok: 1, meta: '{"k":1}' } + // i.e. a declared `Field.boolean` answered `1` and a declared `Field.object` + // answered the stored JSON TEXT. `formatOutput` rather than + // `presentReadValue` is what runs here, and that choice is load-bearing: + // {@link ReadPresentationKind} has no `json` member, so the per-value + // helper the other two doors use cannot present `meta` at all. These are + // ROWS, which is exactly what `formatOutput` takes. + // + // ── The alias columns are carved out, and that is the whole design ────── + // + // A window alias is a COMPUTED value, not a declared field, so no declared + // field's presentation rule may touch it. The case that forces the rule to + // be explicit is an alias that COLLIDES with a declared field name, and SQL + // has already decided that one: `select *` plus ` as ok` projects + // two columns named `ok` and the row object keeps the LAST, so the computed + // value wins the key and the declared column's value is not in the row at + // all. Measured on `main` at `2e6a2ea4c9` with `alias: 'ok'` over a + // declared `Field.boolean ok`: rows came back `ok: 1` and `ok: 2` — the + // ROW_NUMBERs, not the booleans (`true`/`false`), which is how you can tell + // them apart. So the alias wins the key BEFORE this change and still wins + // it after; what this carve-out prevents is presenting that computed number + // as the declared type, which would have turned ROW_NUMBER 1 and 2 into + // `true` and `true` and destroyed the very value the caller asked for. + // Pinned by `sql-driver-window-function-output.test.ts`. + // + // Snapshot-and-restore rather than a "which keys would `formatOutput` + // touch?" pre-computation: that question can only be answered by re-reading + // the declared-field registries `formatOutput` reads, which would be a + // second, worse copy of it — and one that goes silently stale the next time + // `formatOutput` learns a new rule. Restoring a value the pass never + // touched is a no-op, so the cheap-looking version buys nothing. + const aliases = Array.isArray(query.windowFunctions) + ? query.windowFunctions.map((wf) => String(wf.alias)) + : []; + + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + const computed: [string, any][] = []; + for (const alias of aliases) { + if (Object.prototype.hasOwnProperty.call(row, alias)) computed.push([alias, row[alias]]); + } + // Mutates in place and returns the same row, as `findRows()` relies on. + this.formatOutput(object, row); + for (const [alias, value] of computed) row[alias] = value; + } + + return rows; } // =================================== From d257234a75ee5263ca001f6debb7734f43eeb765 Mon Sep 17 00:00:00 2001 From: os-musk Date: Tue, 8 Sep 2026 05:30:10 +0000 Subject: [PATCH 2/3] test(driver-sql): live-dialect arm for the window door, and the full FROM/TO table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH --- .../window-functions-row-presentation.md | 31 ++- ...iver-13973-canonical-iso-read-door.test.ts | 12 +- .../sql-driver-window-function-output.test.ts | 185 +++++++++++++++++- 3 files changed, 223 insertions(+), 5 deletions(-) diff --git a/.changeset/window-functions-row-presentation.md b/.changeset/window-functions-row-presentation.md index 2da0c933a0..3da7076b6f 100644 --- a/.changeset/window-functions-row-presentation.md +++ b/.changeset/window-functions-row-presentation.md @@ -32,15 +32,44 @@ other read door no longer produces — so on the live dialects the divergence wa between this door and the driver's own declared read contract, not merely between dialects. +**What moves, FROM → TO, per column class and per dialect.** Routing this door +through `formatOutput` moves SEVEN classes, not only the boolean and JSON ones +the defect was reported as. `unchanged` means the storage form on that dialect +already WAS the presented form, so the row is byte-identical there — it is +recorded rather than omitted, because the same code path now runs for it. + +| class | sqlite | postgres | mysql | +|---|---|---|---| +| `Field.boolean` | `1` / `0` → `true` / `false` | unchanged (native `boolean`) | `1` / `0` → `true` / `false` | +| `Field.object` (JSON) | `'{"k":1}'` TEXT → `{ k: 1 }` | unchanged (native `jsonb`) | unchanged (mysql2 parses JSON) | +| numeric fields | `'4'` → `4` (a numeric STRING off a legacy TEXT-affinity column) | unchanged | unchanged | +| `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'` | +| `Field.date` | unchanged (`toDateOnly` on text is identity) | unchanged (the driver pins the `date` OID parser to text) | `Date` → `'2026-01-10'` | +| `Field.time` | unchanged | `'09:30:00.5'` → `'09:30:00.500'` | → canonical `HH:MM:SS[.fff]` | +| `external.columnMap` | the row KEY renames: remote column key → local field key | same | same | + +The instant TO is the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` TEXT **on every +dialect**, never a JS `Date` — that is ADR-0053 D-F1 as #16619 landed it, and +this door now runs the same presenter, so it answers the same shape the other +read doors do. + +`external.columnMap` is the one class nobody named on the card, and it is a KEY +move rather than a value move: on an external object with a `columnMap`, the row +this door returns is now keyed by the LOCAL field names, as `find()` has always +keyed it, instead of by the remote physical column names. + **What to do.** Code that compensated for the storage forms stops being correct and should simply drop the compensation: - `if (row.ok === 1)` → `if (row.ok)`; the value is a real boolean now. - `JSON.parse(row.meta)` → `row.meta`; it is already the parsed value, and parsing an object throws. +- `Number(row.amount)` → `row.amount`; a numeric column is a `number`. - A `Field.datetime` / `Field.date` / `Field.time` / `created_at` / `updated_at` read through this door is now the same presented value `find()` gives, so a - branch that re-normalised it can go. + branch that re-normalised it — or that called `Date` methods on it — can go. +- A reader of an external object with a `columnMap` indexes the row by the LOCAL + field key, not the remote column key. **The alias columns are carved out**, which is the design question this door raised. A window alias is a computed value, not a declared field, so no declared diff --git a/packages/drivers/driver-sql/src/sql-driver-13973-canonical-iso-read-door.test.ts b/packages/drivers/driver-sql/src/sql-driver-13973-canonical-iso-read-door.test.ts index af9d31415a..1142c0548b 100644 --- a/packages/drivers/driver-sql/src/sql-driver-13973-canonical-iso-read-door.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-13973-canonical-iso-read-door.test.ts @@ -17,9 +17,15 @@ * an Invalid `Date`, which has no canonical text and passes through as the * `Date` it is (pinned by `sql-driver-14078-invalid-date-materialisation.test.ts`; * never met here, because this fixture writes only valid instants). - * `findWithWindowFunctions` is not one of those doors: it applies no read - * presentation of any kind, D-F1 records it as not covered, and #16609 holds - * it. + * `findWithWindowFunctions` used to be the one read door outside this list: it + * applied no read presentation of any kind. Since #16609 it routes each row + * through the SAME `formatOutput` pass `find()` runs (minus the window-alias + * columns), so it presents these two column classes exactly as the doors above + * do — pinned by `sql-driver-window-function-output.test.ts`, whose live cells + * assert the canonical instant on Postgres and MySQL for this door too. + * ⚠️ ADR-0053 D-F1 still RECORDS that door as not covered; the tree is ahead of + * the declaration there, and docs-only governed card #16782 carries the + * amendment. Do not read the ADR line as the current behaviour. * * §A1–§A3 measure the four row doors on the fixture table; §A5–§A7 the three * write doors whose return is a row, on a second table so their writes cannot diff --git a/packages/drivers/driver-sql/src/sql-driver-window-function-output.test.ts b/packages/drivers/driver-sql/src/sql-driver-window-function-output.test.ts index e601c4c76f..e84553d1de 100644 --- a/packages/drivers/driver-sql/src/sql-driver-window-function-output.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-window-function-output.test.ts @@ -22,10 +22,38 @@ * * The alias carve-out is pinned here too — see the collision block at the * bottom, which is the design question the card left to the implementer. + * + * ## Two arms, and why the SQLite one is not the whole file + * + * The first `describe` below is the SQLite agreement arm. It cannot measure two + * halves of this door's contract, and says so rather than letting a green stand + * in for them: + * + * - the BOOLEAN rule fires under `isSqlite || isMysql` (`sql-driver.ts` + * `formatOutput`), so its MySQL half is invisible on SQLite; + * - the INSTANT classes are stored canonically on SQLite since #3912, so + * removing the read presentation moves nothing for them there — the five + * instant agreements CANNOT fail on this dialect. On Postgres and MySQL the + * client library hands the driver a `Date`, so the fold is real work. + * + * The `measure(cell)` arm at the bottom is those two halves, over `DIALECT_CELLS` + * — the ADR-0053 D-A3 driver axis, declared through `declareDialectCell` so an + * unprovisioned cell is a NAMED SKIP and never a silent pass. Locally (no + * `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL`) the two live cells report as + * skips and this door's MySQL boolean half and PG/MySQL instant fold are NOT + * MEASURED; the `Temporal Conformance (live PG + MySQL)` job provisions both and + * measures them there. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { SqlDriver } from '../src/index.js'; +import { + DIALECT_CELLS, + assertThreeWayZoneSkew, + declareDialectCell, + readServerZone, + type DialectCell, +} from './live-dialect-matrix.testkit.js'; const TABLE = 'window_row'; @@ -195,3 +223,158 @@ describe('rows leaving findWithWindowFunctions() (#16609)', () => { }); }); }); + +// ── THE LIVE-DIALECT ARM ───────────────────────────────────────────────────── +// +// The two halves the SQLite arm above cannot measure, on the ADR-0053 D-A3 +// driver axis. Everything here is asserted ABSOLUTELY rather than as a +// door-to-door agreement where the absolute shape is the point: an agreement +// between two doors that are both wrong is green, and the MySQL boolean half +// and the PG/MySQL instant fold are exactly the places this door had never been +// measured at all. + +const LIVE_TABLE = 'os16609_window_row'; + +/** The canonical instant text — the ONE shape every read door presents. */ +const LIVE_ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +/** The instant classes this door now folds: the audit stamps + a `Field.datetime`. */ +const LIVE_INSTANT_COLUMNS = ['closed_at', 'created_at', 'updated_at'] as const; + +const LIVE_CLOSED_AT = ['2026-01-10T09:00:00.123Z', '2026-02-14T21:30:45.678Z'] as const; + +/** Spelled once, the same assertion `sql-driver-13973-…` makes for the other doors. */ +function expectCanonicalInstant(value: unknown, label: string): void { + expect(value, `${label}: the window door did not return the column`).toBeDefined(); + expect(value, `${label}: null`).not.toBeNull(); + expect( + value instanceof Date, + `${label}: findWithWindowFunctions handed out a JS Date (${String(value)}) — ADR-0053 D-F1 ` + + `rules the canonical text on every dialect, and this door runs the same formatOutput pass`, + ).toBe(false); + expect(typeof value, `${label}: type`).toBe('string'); + expect(value, `${label}: shape`).toMatch(LIVE_ISO_Z); +} + +function measure(cell: DialectCell): void { + describe(`#16609 — findWithWindowFunctions presents on every dialect (${cell.label})`, () => { + let driver: SqlDriver; + let windowed: any[] = []; + let found: any[] = []; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + // A live cell proves nothing unless server, process and UTC disagree — + // the same guard every other matrix consumer runs. + if (cell.live) assertThreeWayZoneSkew(cell, await readServerZone(cell, driver)); + await driver.execute(`drop table if exists ${LIVE_TABLE}`).catch(() => {}); + await driver.initObjects([{ name: LIVE_TABLE, fields: FIELDS as any }] as any); + for (const [i, iso] of LIVE_CLOSED_AT.entries()) { + await driver.create( + LIVE_TABLE, + { + id: `w${i}`, + // Row 0 declares `true`, row 1 `false` — a rule that folded every + // value one way could not pass both. + ok: i === 0, + meta: { k: i }, + amount: 10 + i, + region: i === 0 ? 'east' : 'west', + // Bound as a JS `Date`, which is the shape whose fold this measures. + closed_at: new Date(iso), + closed_on: '2026-01-10', + starts_at: '09:30:00.500', + }, + { bypassTenantAudit: true }, + ); + } + const query = { windowFunctions: [ROW_NUMBER], orderBy: [{ field: 'id', order: 'asc' as const }] }; + windowed = await driver.findWithWindowFunctions(LIVE_TABLE, query as any, { bypassTenantAudit: true }); + found = await driver.find( + LIVE_TABLE, + { orderBy: [{ field: 'id', order: 'asc' }] }, + { bypassTenantAudit: true }, + ); + }, 60_000); + + afterAll(async () => { + await driver?.execute(`drop table if exists ${LIVE_TABLE}`).catch(() => {}); + await driver?.disconnect(); + }); + + it('§L0 the fixture is non-vacuous: the window door returned both rows carrying every column under test', () => { + // Every assertion below reads these keys off these rows; a door that did + // not select a column would let them all pass having checked nothing. + expect(windowed).toHaveLength(LIVE_CLOSED_AT.length); + for (const row of windowed) { + for (const col of [...LIVE_INSTANT_COLUMNS, 'ok', 'rn'] as const) { + expect(row[col], `${row.id}.${col} missing from the window row`).toBeDefined(); + expect(row[col], `${row.id}.${col} is null`).not.toBeNull(); + } + } + }); + + it('§L1 a declared Field.boolean answers a boolean, not 1/0', () => { + // The MySQL half of the `isSqlite || isMysql` gate in `formatOutput` — + // `tinyint(1)`, which mysql2 hands back as a JS number. Unmeasurable on + // the SQLite-only arm above, and the reason this cell exists. + for (const row of windowed) { + expect(typeof row.ok, `${row.id}.ok on ${cell.label}`).toBe('boolean'); + } + expect(windowed.map((r) => r.ok)).toEqual([true, false]); + }); + + it('§L2 the instant classes are canonical ISO-Z text, never a Date', () => { + // ADR-0053 D-F1 through this door: the audit stamps and every declared + // `Field.datetime`. On PG/MySQL the client hands the driver a `Date`, so + // this is the fold doing real work — see §L4. + for (const row of windowed) { + for (const col of LIVE_INSTANT_COLUMNS) expectCanonicalInstant(row[col], `${row.id}.${col}`); + } + expect(windowed.map((r) => r.closed_at)).toEqual([...LIVE_CLOSED_AT]); + }); + + it('§L3 the window row equals the find() row on every declared column', () => { + expect(found).toHaveLength(windowed.length); + for (let i = 0; i < found.length; i++) { + for (const column of DECLARED_COLUMNS) { + expect(typeof windowed[i][column], `typeof ${column} on row ${i} (${cell.label})`).toBe( + typeof found[i][column], + ); + expect(windowed[i][column], `${column} on row ${i} (${cell.label})`).toEqual(found[i][column]); + } + } + }); + + it("§L4 the fold is the driver's, not the client's: raw knex still materialises the dialect's own shape", async () => { + const raw: any = await (driver as any).knex(LIVE_TABLE).where('id', 'w0').first(); + expect(raw, 'raw read returned nothing').toBeTruthy(); + if (cell.live) { + // Postgres (`timestamptz`) and MySQL (`DATETIME(3)`) hand a `Date` to + // the driver — D-F2: the client parser is untouched, the driver folds at + // its own read boundary. This is what makes §L2 a measurement rather + // than a restatement of the client's behaviour. + for (const col of LIVE_INSTANT_COLUMNS) { + expect( + raw[col] instanceof Date, + `${cell.label} raw ${col} is ${typeof raw[col]} (${String(raw[col])}) — the client ` + + `parser was changed, which the #13973 ruling forbids`, + ).toBe(true); + } + expect((raw.closed_at as Date).toISOString()).toBe(windowed[0].closed_at); + } + if (cell.id === 'mysql') { + // Same for the boolean: `tinyint(1)` off the raw client is a number, so + // §L1's `boolean` on this cell was produced by `formatOutput`. + expect(typeof raw.ok, 'mysql raw ok').toBe('number'); + } + }); + }); +} + +// A matrix that silently finds zero cells reports OK — every cell is declared +// EITHER WAY, measured when it is provisioned and a NAMED SKIP when it is not +// (a named RED under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`). +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, 'window-function row presentation (#16609)', measure); +} From ea93dbea526f5a0084c742686dee5875951fe224 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 05:44:08 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(changeset):=20reword=20the=20FROM?= =?UTF-8?q?=E2=86=92TO=20heading=20so=20the=20ADR-0087=20gate=20reads=20no?= =?UTF-8?q?=20prescription=20(#16609)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH --- .changeset/window-functions-row-presentation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/window-functions-row-presentation.md b/.changeset/window-functions-row-presentation.md index 3da7076b6f..7dfcd8b49e 100644 --- a/.changeset/window-functions-row-presentation.md +++ b/.changeset/window-functions-row-presentation.md @@ -32,7 +32,7 @@ other read door no longer produces — so on the live dialects the divergence wa between this door and the driver's own declared read contract, not merely between dialects. -**What moves, FROM → TO, per column class and per dialect.** Routing this door +**What moves, per column class and per dialect (storage form → presented form).** Routing this door through `formatOutput` moves SEVEN classes, not only the boolean and JSON ones the defect was reported as. `unchanged` means the storage form on that dialect already WAS the presented form, so the row is byte-identical there — it is