1111 * aggregate that answers differently on one of them is one driver giving one
1212 * query two numbers, and only a shared table run on both can see it.
1313 *
14- * ## Why a real better-sqlite3 database and not a SQL-string assertion
14+ * ## Why a real database and not a SQL-string assertion
1515 *
1616 * `count_distinct` is the first entry in the vocabulary whose lowering is not a
1717 * function name — `COUNT(DISTINCT x)` puts a keyword inside the argument list.
2222 * instrument choice `turso-remote-filter-logic-conformance.test.ts` makes, and
2323 * for the same reason.
2424 *
25+ * ## [#11456] The DRIVER axis — this suite runs on every dialect, not one
26+ *
27+ * It used to construct `client: 'better-sqlite3'` as a literal, so the shared
28+ * aggregate-vocabulary standard — which exists precisely so two faces of one
29+ * platform cannot answer one aggregation two ways — was measured on exactly one
30+ * dialect of a driver that speaks three. ADR-0053 D-A3 makes the matrix
31+ * `driver {SQLite, Postgres at minimum}`; its pagination sibling already went
32+ * through `live-dialect-matrix.testkit.ts`. It now does too, so `pg` and
33+ * `mysql` cells execute whenever `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL`
34+ * are provisioned, and are a NAMED skip (a red under
35+ * `OS_EXPECT_LIVE_DIALECT_MATRIX=1`) when they are not.
36+ *
37+ * ⛔ **No case is dialect-gated.** All eighteen tests run on every available
38+ * cell; the conversion narrows nothing. Two of them needed a per-dialect FACE
39+ * rather than a per-dialect exemption, and the difference matters — a `skipIf`
40+ * would have been the silent narrowing this card was filed about.
41+ *
42+ * Measured under full CI parity (`OS_EXPECT_LIVE_DIALECT_MATRIX=1`, both URLs
43+ * provisioned, `TZ=America/New_York`): **55 passed, 0 skipped** — 18 per cell
44+ * on all three, plus the axis guard. The suite it replaced ran 18, on one.
45+ *
46+ * ### The readings the faces are built from, measured 2026-08-25
47+ *
48+ * All three cells on live servers at CI parity — PostgreSQL 16.13
49+ * (`timezone=Asia/Shanghai`), MySQL 8.0.46 (`@@global.time_zone='+08:00'`),
50+ * process `TZ=America/New_York`. Measured, not ported:
51+ *
52+ * | reading | sqlite | pg | mysql |
53+ * | ----------------------------------- | ------------- | -------------- | ------------- |
54+ * | `count` / `count_distinct` result | `6` number | `"6"` STRING | `6` number |
55+ * | `sum`/`avg`/`min`/`max` over `score` | `210` number | `210` number | `210` number |
56+ * | identifier quoting in emitted SQL | `` `stage` `` | `"stage"` | `` `stage` `` |
57+ * | field-less `count_distinct` refusal | `INVALID_QUERY`/400 | identical | identical |
58+ *
59+ * ⚠️ Two things in that table are worth reading slowly, because the tempting
60+ * summary — "the live servers answer differently" — is wrong in both.
61+ *
62+ * FIRST: the divergent cell is **pg alone**, not "the live cells". mysql2 hands
63+ * `COUNT`'s `BIGINT` back as a JS number; node-pg hands the same `BIGINT` back
64+ * as a string rather than lose precision. So this is a property of one CLIENT
65+ * LIBRARY's type parsing, and a face switched on `cell.live` would have been
66+ * built on a distinction that is not the one doing the work.
67+ *
68+ * SECOND: within pg it is not uniform either. `count` arrives as a string while
69+ * `sum`/`avg`/`min`/`max` over this fixture's `score` arrive as numbers,
70+ * because `type: 'number'` lowers to a float column and node-pg parses float8
71+ * to a number. The wire type is a property of the RESULT type per aggregate,
72+ * not of the dialect — which is why the coercion below is unconditional rather
73+ * than switched on `cell.id`. A `cell.id === 'pg'` branch would be green today
74+ * and wrong the first time an aggregand's column type changed.
75+ *
76+ * {@link actualFor}'s `Number(r.n)` was already the right shape and needed no
77+ * change; it is now load-bearing rather than incidental, and that is why it is
78+ * spelled out here. The two tests that did NOT go through it are the two that
79+ * needed work — see their own notes.
80+ *
2581 * ## Reverse verification — direction predicted BEFORE it was run
2682 *
83+ * Measured on the SQLite cell (the only one that existed when they were taken).
2784 * Two reverts, because the two mistakes this file guards against fail in
2885 * different ways and only one of them needs a database to see.
2986 *
58115 * non-distinct lowering has nothing to refuse. `count_distinct(score)` stayed
59116 * green throughout, exactly as predicted, which is why the table carries both
60117 * columns.
118+ *
119+ * ## [#11456] Ablation — that the NEW cells execute, and can fail
120+ *
121+ * A converted matrix that finds zero live cells reports OK, so the conversion's
122+ * own claim needed its own measurement. Predicted BEFORE running: mutating the
123+ * `count_distinct` lowering to drop its `distinct` — revert (B) above, whose
124+ * SQLite direction is already recorded — reddens the `live postgres` cell on
125+ * the SAME four cases, proving that cell runs the assertions rather than
126+ * merely connecting.
127+ *
128+ * Measured on live PostgreSQL 16.13, the mutation confirmed on disk by anchored
129+ * counts (`distinct: true` 1 → 0, `distinct: false` 0 → 1) before any result
130+ * was read: **8 failed / 29 passed / 1 skipped**, four in EACH executing cell —
131+ * exactly the predicted direction and no other movement.
132+ *
133+ * The `live postgres` four, verbatim:
134+ *
135+ * - `count_distinct(stage)` ungrouped: `value: 4` where the case says `2`;
136+ * - `count_distinct(stage) grouped by region`: `west` 3 where the case says 2;
137+ * - the emitted-SQL case, on
138+ * `expected 'select count("stage") as "n" from "co…' to contain 'count(distinct "stage")'`;
139+ * - the field-less refusal, now RESOLVING (`expected undefined to be defined`)
140+ * — `count(*)` is valid, so a non-distinct lowering has nothing to refuse.
141+ *
142+ * ⚠️ The third one is the load-bearing observation for this conversion, and it
143+ * is why the derived quoting above is not circular: the PG cell failed against
144+ * `count(distinct "stage")` — pg's OWN quoting, derived from pg's OWN client —
145+ * while the SQLite cell failed against the backtick spelling in the same run.
146+ * One assertion, two dialect-correct expectations, both of them able to fail.
147+ * `count_distinct(score)` stayed GREEN on both cells throughout, as predicted,
148+ * which is what keeps the four above from being a suite that simply broke.
149+ *
150+ * The mutation was restored and the restore VERIFIED rather than trusted —
151+ * `sql-driver.ts` re-read as byte-identical to `origin/main`, and the suite
152+ * re-run green. That check earned its keep: the `trap … EXIT INT TERM` fired
153+ * but its `git checkout` used a repo-relative path after the script had `cd`'d
154+ * into the package, so git answered `error: pathspec … did not match any
155+ * file(s)` and the trap's own success line printed anyway. A restore leg that
156+ * reports success and does nothing leaves every later measurement running
157+ * against mutated code.
61158 */
62159
63160import { describe , it , expect , beforeAll , afterAll } from 'vitest' ;
64161import { AGGREGATION_CASES , AGGREGATION_ROWS } from '@objectstack/spec/data' ;
65162import type { AggregationCase , QueryAST } from '@objectstack/spec/data' ;
66163import { SqlDriver } from './index.js' ;
164+ import {
165+ DIALECT_CELLS ,
166+ declareDialectCell ,
167+ type DialectCell ,
168+ } from './live-dialect-matrix.testkit.js' ;
67169
68170const CONFORMANCE_OBJECT = {
69171 name : 'conformance_agg' ,
@@ -92,7 +194,8 @@ const astFor = (c: AggregationCase): QueryAST => ({
92194 * The rows a case must produce, in the table's own order: `group` ascending for
93195 * a grouped case, one `null`-grouped row otherwise. Numbers are compared as
94196 * numbers — SQLite hands `avg` back as a float and `count` as an integer, and
95- * neither is the property under test.
197+ * [#11456] node-pg hands `count` back as the STRING `"6"` because `COUNT` is
198+ * `bigint`. None of those is the property under test; the value is.
96199 */
97200const actualFor = ( c : AggregationCase , rows : Array < Record < string , unknown > > ) => {
98201 // [#6401] The group value is read from the column the case SAYS it lands in —
@@ -104,20 +207,23 @@ const actualFor = (c: AggregationCase, rows: Array<Record<string, unknown>>) =>
104207 . sort ( ( x , y ) => String ( x . group ) . localeCompare ( String ( y . group ) ) ) ;
105208} ;
106209
107- describe ( '[#6409] SqlDriver — aggregate vocabulary conformance' , ( ) => {
210+ function declareAggregateVocabularyConformance ( cell : DialectCell ) : void {
211+ describe ( `[#6409] SqlDriver — aggregate vocabulary conformance (${ cell . label } )` , ( ) => {
108212 let driver : SqlDriver ;
109213
110214 beforeAll ( async ( ) => {
111- driver = new SqlDriver ( {
112- client : 'better-sqlite3' ,
113- connection : { filename : ':memory:' } ,
114- useNullAsDefault : true ,
115- } ) ;
215+ driver = new SqlDriver ( cell . config ( ) ) ;
216+ // The live cells own a per-FILE schema that a killed run can leave behind
217+ // (`live-dialect-matrix.globalsetup.ts`'s teardown is best-effort by
218+ // design), and a surviving table would seed twelve rows into a fixture
219+ // whose every case counts six.
220+ await driver . execute ( `drop table if exists ${ CONFORMANCE_OBJECT . name } ` ) . catch ( ( ) => { } ) ;
116221 await driver . initObjects ( [ CONFORMANCE_OBJECT as any ] ) ;
117222 for ( const row of AGGREGATION_ROWS ) await driver . create ( CONFORMANCE_OBJECT . name , { ...row } ) ;
118223 } ) ;
119224
120225 afterAll ( async ( ) => {
226+ await driver . execute ( `drop table if exists ${ CONFORMANCE_OBJECT . name } ` ) . catch ( ( ) => { } ) ;
121227 await driver . disconnect ( ) ;
122228 } ) ;
123229
@@ -155,6 +261,16 @@ describe('[#6409] SqlDriver — aggregate vocabulary conformance', () => {
155261 * property values cannot show: that the column arrives as a bound IDENTIFIER
156262 * (`??`) rather than interpolated into the statement text, which is what
157263 * keeps a caller's field name out of the SQL when `distinct` is in play.
264+ *
265+ * [#11456] The quoting is DERIVED from the cell's own dialect rather than
266+ * spelled per cell. Measured: better-sqlite3 emits `` `stage` `` and pg emits
267+ * `"stage"`, so the old literal backticks were a SQLite fact this suite was
268+ * asserting about every dialect. A per-cell table of quote characters would
269+ * work too and was rejected: the quote character is not the property under
270+ * test, and a hand-kept table would need an entry the day a fourth dialect
271+ * arrives — this cannot go stale. What IS asserted stays exactly what it was:
272+ * the keyword `distinct` unquoted and INSIDE the parentheses, and the column
273+ * bound as an identifier rather than interpolated as a string literal.
158274 */
159275 it ( 'count_distinct compiles to count(distinct "column"), the column bound as an identifier' , async ( ) => {
160276 const knex = ( driver as any ) . knex ;
@@ -169,9 +285,8 @@ describe('[#6409] SqlDriver — aggregate vocabulary conformance', () => {
169285 knex . removeListener ( 'query' , capture ) ;
170286 }
171287 expect ( statements ) . toHaveLength ( 1 ) ;
172- // better-sqlite3 quotes identifiers with backticks; the keyword is SYNTAX,
173- // so it must appear unquoted and INSIDE the parentheses.
174- expect ( statements [ 0 ] ) . toContain ( 'count(distinct `stage`)' ) ;
288+ const quoted = knex . client . wrapIdentifier ( 'stage' , ( x : string ) => x ) ;
289+ expect ( statements [ 0 ] ) . toContain ( `count(distinct ${ quoted } )` ) ;
175290 // ⛔ The field must not have been interpolated as a string literal.
176291 expect ( statements [ 0 ] ) . not . toContain ( "'stage'" ) ;
177292 } ) ;
@@ -181,6 +296,10 @@ describe('[#6409] SqlDriver — aggregate vocabulary conformance', () => {
181296 * emitting it. ADR-0112: the assertion is `code` AND `status`, never a bare
182297 * `toThrow` — the un-fixed driver threw here too (a 501 from the aggregate
183298 * door), so "it threw" cannot tell the two behaviours apart.
299+ *
300+ * [#11456] Measured identical on live pg: this refusal is raised by the
301+ * driver before any dialect is consulted, which is the answer this cell is
302+ * here to confirm rather than assume.
184303 */
185304 it ( 'refuses count_distinct with no field — INVALID_QUERY / 400' , async ( ) => {
186305 const ast = {
@@ -206,12 +325,41 @@ describe('[#6409] SqlDriver — aggregate vocabulary conformance', () => {
206325 /**
207326 * The control that keeps the refusal above from being satisfiable by refusing
208327 * the field-less spelling in general: `count` still means `COUNT(*)`.
328+ *
329+ * [#11456] It used to read `toEqual([{ n: 6 }])`, which carried three facts —
330+ * one row, the alias `n` as its ONLY key, and the value 6 — and could not
331+ * survive the live cell, because pg answers `{ n: "6" }`: `COUNT` is `bigint`
332+ * and node-pg hands bigints back as strings rather than lose precision. All
333+ * three facts are kept and spelled out separately instead. ⛔ Not relaxed to
334+ * a bare `toHaveProperty`: the key set is the DRIVER's own projection (it
335+ * emits an explicit `as "n"`, never `*`), so it is dialect-independent and
336+ * dropping it would be the silent narrowing this conversion exists to avoid.
209337 */
210338 it ( 'count with no field still means COUNT(*)' , async ( ) => {
211339 const ast = {
212340 object : CONFORMANCE_OBJECT . name ,
213341 aggregations : [ { function : 'count' , alias : 'n' } ] ,
214342 } as QueryAST ;
215- expect ( await driver . aggregate ( CONFORMANCE_OBJECT . name , ast ) ) . toEqual ( [ { n : 6 } ] ) ;
343+ const rows = ( await driver . aggregate ( CONFORMANCE_OBJECT . name , ast ) ) as Array < Record < string , unknown > > ;
344+ expect ( rows ) . toHaveLength ( 1 ) ;
345+ expect ( Object . keys ( rows [ 0 ] ) , 'the alias is the only projected column' ) . toEqual ( [ 'n' ] ) ;
346+ expect ( Number ( rows [ 0 ] . n ) , 'count(*) over the six-row fixture' ) . toBe ( 6 ) ;
216347 } ) ;
217348} ) ;
349+ }
350+
351+ /**
352+ * [#11456] A matrix that silently finds zero cells reports OK — so the axis is
353+ * asserted to be real before it is iterated, the same guard the #11635 suite
354+ * carries. Without it, "converted to the live matrix" is a claim this file
355+ * could satisfy while running nothing at all.
356+ */
357+ describe ( '[#11456] the dialect axis this suite runs' , ( ) => {
358+ it ( 'runs every dialect this driver speaks' , ( ) => {
359+ expect ( DIALECT_CELLS . map ( ( c ) => c . id ) ) . toEqual ( [ 'sqlite' , 'pg' , 'mysql' ] ) ;
360+ } ) ;
361+ } ) ;
362+
363+ for ( const cell of DIALECT_CELLS ) {
364+ declareDialectCell ( cell , 'aggregate vocabulary conformance' , declareAggregateVocabularyConformance ) ;
365+ }
0 commit comments