Skip to content

Commit 9b9b429

Browse files
committed
chore(gates): classify AGGREGATION_CASES in the driver conformance matrix (#6409)
Enrols driver-sql, driver-turso and driver-sqlite-wasm; records honest DEBT rows for the two #5499-frozen packages (tracked as #6814). Regenerates the spec API surface for the new shared exports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWMbS2ctjZSADPJiSdUtMA
1 parent 9ee2c59 commit 9b9b429

4 files changed

Lines changed: 147 additions & 4 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#6409] Aggregate-vocabulary conformance for the wasm driver — the shared
5+
* `@objectstack/spec/data` cases, run through this driver's own pipeline.
6+
*
7+
* `SqliteWasmDriver extends SqlDriver`, so the `count(distinct x)` lowering is
8+
* inherited and nothing here re-implements it. What this pins is the other
9+
* half, the same half its filter-logic, temporal and pagination suites pin: the
10+
* compiled statement has to survive a different **engine**. This driver swaps
11+
* knex's transport for a custom sql.js dialect (`Client_WasmSqlite`) that
12+
* compiles the statement, binds its parameters and marshals the rows back
13+
* through its own path.
14+
*
15+
* That last step is why this cell is not a formality. The other five aggregates
16+
* come back as a plain scalar; `COUNT(DISTINCT …)` does too, but it is the one
17+
* whose column expression carries a KEYWORD the dialect has to pass through
18+
* untouched, and it is the one whose wrong answers are all valid numbers. A
19+
* dialect that mangled the expression, or that marshalled the count back
20+
* through a different type path, would fail here and in no other suite.
21+
*
22+
* "It inherits the compiler, therefore it is fine" is the assumption these
23+
* suites exist to disprove — the judgement #4405 recorded for this driver's
24+
* filter-logic cell, applied to the aggregate column.
25+
*/
26+
27+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
28+
import { AGGREGATION_CASES, AGGREGATION_ROWS } from '@objectstack/spec/data';
29+
import type { AggregationCase, QueryAST } from '@objectstack/spec/data';
30+
import { SqliteWasmDriver } from './index.js';
31+
32+
const OBJECT = 'conformance_agg';
33+
34+
const astFor = (c: AggregationCase): QueryAST => ({
35+
object: OBJECT,
36+
aggregations: [{ function: c.function, ...(c.field ? { field: c.field } : {}), alias: 'n' }],
37+
...(c.groupBy ? { groupBy: [c.groupBy] } : {}),
38+
});
39+
40+
const actualFor = (c: AggregationCase, rows: Array<Record<string, unknown>>) =>
41+
rows
42+
.map((r) => ({ group: c.groupBy ? String(r[c.groupBy]) : null, value: Number(r.n) }))
43+
.sort((x, y) => String(x.group).localeCompare(String(y.group)));
44+
45+
describe('[#6409] driver-sqlite-wasm — aggregate vocabulary conformance', () => {
46+
let driver: SqliteWasmDriver;
47+
48+
beforeAll(async () => {
49+
driver = new SqliteWasmDriver({ filename: ':memory:' });
50+
await driver.initObjects([
51+
{
52+
name: OBJECT,
53+
fields: {
54+
region: { type: 'string' },
55+
// Nullable — the DDL this driver generates leaves a plain string
56+
// column nullable, which is what the null-bearing rows need.
57+
stage: { type: 'string' },
58+
score: { type: 'number' },
59+
},
60+
},
61+
]);
62+
for (const row of AGGREGATION_ROWS) {
63+
await driver.create(OBJECT, { ...row }, { bypassTenantAudit: true } as any);
64+
}
65+
});
66+
67+
afterAll(async () => {
68+
await driver.disconnect();
69+
});
70+
71+
it('the fixture is all six rows, with the nulls stored AS nulls', async () => {
72+
const rows = await driver.find(OBJECT, { sort: [{ field: 'id', order: 'asc' }] });
73+
expect(rows).toHaveLength(6);
74+
expect((rows as any[]).filter((r) => r.stage === null)).toHaveLength(2);
75+
});
76+
77+
for (const c of AGGREGATION_CASES) {
78+
it(c.name, async () => {
79+
const rows = await driver.aggregate(OBJECT, astFor(c));
80+
expect(actualFor(c, rows as any[]), c.note).toEqual([...c.expected]);
81+
});
82+
}
83+
});

packages/spec/api-surface/data.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"description": "Every exported `name (kind)` of one published entry point of @objectstack/spec — the breadth half of the ADR-0059 backward-compatibility gate. Sharded by entry point (#5837) so two PRs touching different entry points never share a file. Reads the BUILT dist/*.d.ts: regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a real build.",
33
"entry": "./data",
44
"exports": [
5+
"AGGREGATION_CASES (const)",
6+
"AGGREGATION_ROWS (const)",
57
"ALL_OPERATORS (const)",
68
"API_METHOD_DERIVATION (const)",
79
"API_METHOD_ORDER (const)",
@@ -12,12 +14,15 @@
1214
"AddressSchema (const)",
1315
"AddressValue (type)",
1416
"AddressValueSchema (const)",
17+
"AggregationCase (interface)",
18+
"AggregationExpectation (interface)",
1519
"AggregationFunction (type)",
1620
"AggregationMetricType (type)",
1721
"AggregationNode (type)",
1822
"AggregationNodeSchema (const)",
1923
"AggregationPipeline (type)",
2024
"AggregationPipelineSchema (const)",
25+
"AggregationRow (interface)",
2126
"AggregationStage (type)",
2227
"AggregationStageSchema (const)",
2328
"AnalyticsQuery (type)",

packages/spec/src/data/query.zod.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,7 @@ export const SortNodeSchema = lazySchema(() => strictObject(
7373
const AGG_RETIRED_MIDDLE =
7474
' was removed from `AggregationFunction` in @objectstack/spec 17 (#6188, ADR-0049 '
7575
+ 'enforce-or-remove) — no SQL backend ever compiled it. `SqlDriver.mapAggregateFunc` and '
76-
+ '`RemoteTransport.aggregate` lower the same set of functions and refuse everything outside '
77-
+ 'it (`count_distinct` joined that set at #6409; these two never did), and '
76+
+ '`RemoteTransport.aggregate` each lower the same set of functions and refuse the rest, and '
7877
+ "the v1 dataset runtime had to subtract this one by name to stop it reaching a `COUNT(*)` "
7978
+ 'fallback that returns a row count in place of the value asked for. On the backend family '
8079
+ 'this platform targets it was a declaration that could only fail. ';

scripts/check-driver-conformance.mjs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,11 @@ const CASE_SETS = [
161161
marker: 'FILTER_TEXT_CASES',
162162
what: 'text operators: ASCII-only case folding, literal comparands, `$regex` refused — #4706/#5701',
163163
},
164+
{
165+
file: 'aggregation-conformance.ts',
166+
marker: 'AGGREGATION_CASES',
167+
what: 'the value each declared AggregationFunction produces, dedup and NULLs included — #6409',
168+
},
164169
];
165170

166171
// ── The ledger ──────────────────────────────────────────────────────────────
@@ -328,8 +333,24 @@ const CASE_SETS = [
328333
// `code` half the case-set requires and the last place a bare `new Error`
329334
// escaped the ADR-0112 envelope.
330335
//
331-
// Each row's `why` is what that driver does TODAY, measured on this branch by
332-
// reading the compiler and executing it. Nothing here is predicted.
336+
// ## AGGREGATION_CASES: two DEBT rows on arrival, and they are the same pair
337+
//
338+
// The column arrived with #6409, which lowered `count_distinct` to
339+
// `COUNT(DISTINCT x)` on the SQL family — the ENFORCE leg of #6188's split
340+
// ruling. Three of the five cells were covered by that PR: `driver-sql` and
341+
// `driver-turso` (its REMOTE transport, an independent compiler, which is what
342+
// the case-set exists to hold against the local one) plus `driver-sqlite-wasm`,
343+
// whose suite pins the inherited statement surviving a different ENGINE, the
344+
// same judgement #4405 recorded for its filter-logic cell.
345+
//
346+
// The two open cells are `driver-memory` and `driver-mongodb` — the #5499 frozen
347+
// family, and open by that decision rather than by difficulty. #6409's ruling
348+
// put both explicitly out of scope and left their partial implementations
349+
// untouched, so the rows below record what each ANSWERS today, read from the
350+
// source on this branch. Neither is a prediction, and neither is a permission
351+
// slip: the cell clears when a suite runs the case-set, not when someone argues
352+
// the driver would pass it. Both would go RED as they stand, which is the
353+
// reason the rows exist rather than a reason to omit them.
333354

334355
const LEDGER = [
335356
{
@@ -381,6 +402,41 @@ const LEDGER = [
381402
+ 'this cell needs a server-free half like `mongodb-filter-logic-translation.test.ts` has.',
382403
issue: 'https://github.com/objectstack-ai/objectstack/issues/6682',
383404
},
405+
{
406+
driver: 'driver-memory',
407+
marker: 'AGGREGATION_CASES',
408+
kind: 'DEBT',
409+
why:
410+
'Measured on this branch by reading `MemoryDriver.computeAggregate` (`memory-driver.ts`): it has arms '
411+
+ 'for count/sum/avg/min/max and then `default: return null`. There is NO `count_distinct` arm, so an '
412+
+ 'aggregation the Query Protocol declares — and that every SQL face now lowers (#6409) — resolves with '
413+
+ '`{ n: null }`: no error, no log, no refusal. The case-set says 2 over `AGGREGATION_ROWS`. That is a '
414+
+ 'wrong ANSWER rather than a wrong number, and it is the `default:`-arm shape the '
415+
+ '`aggregation-lockstep` guard exists to stop one layer up, reached here through a different door. '
416+
+ 'The package is partial in the way #6409\'s ruling described: its ANALYTICS face '
417+
+ '(`memory-analytics.ts`) DOES implement `count_distinct`, so this package answers one declared '
418+
+ 'function two ways depending on which face you enter — the divergence class #5374 fixed for '
419+
+ '`$contains` in this same package. #5499 freezes it, so the cell is open by decision, not by '
420+
+ 'difficulty: the fix is one arm beside its neighbours. Tracked as #6814.',
421+
issue: 'https://github.com/objectstack-ai/objectstack/issues/6814',
422+
},
423+
{
424+
driver: 'driver-mongodb',
425+
marker: 'AGGREGATION_CASES',
426+
kind: 'DEBT',
427+
why:
428+
'Measured on this branch by reading `mongodb-aggregation.ts`: `count_distinct` lowers to '
429+
+ '`{ $addToSet: fieldRef ?? null }` and `postProcessAggregation` takes the array\'s `.length`. '
430+
+ '`$addToSet` adds an explicit `null` to the set, so a nullable column sizes ONE HIGHER than '
431+
+ '`COUNT(DISTINCT col)` does — 3 where the case-set says 2 over `AGGREGATION_ROWS`. `$addToSet` on a '
432+
+ 'MISSING field adds nothing, so the divergence shows only for an explicitly-null value, which is '
433+
+ 'exactly what the fixture seeds and what a nullable column produces in practice. Not executed — this '
434+
+ 'package has no suite for the cell, which is the debt. #5499 freezes it; the fix is a `$ne: null` '
435+
+ 'before the `$addToSet` (or sizing a `$setDifference` against `[null]`). Tracked as #6814. Note the '
436+
+ 'real-mongod suites are opt-in since #5517, so whatever clears this cell needs a server-free half '
437+
+ 'like `mongodb-filter-logic-translation.test.ts` has.',
438+
issue: 'https://github.com/objectstack-ai/objectstack/issues/6814',
439+
},
384440
];
385441

386442
// ── Discovery ───────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)