Skip to content

Commit 3f7f14e

Browse files
os-zhuangclaude
andauthored
refactor(spec,objectql)!: retire AggregationNode.distinct — one face honoured it, five ignored it (#6815, ADR-0049) (#7051)
`AggregationNode.distinct` was read by exactly one of the six faces that consume an `aggregations[]` entry: objectql's in-memory fallback deduplicated before applying the function, while driver-sql, driver-turso, driver-mongodb, driver-memory and service-analytics' AGGREGATE_SQL all ignored it. So `{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum on the fallback path and an ordinary sum on every SQL datasource — one query, two plausible numbers, chosen by which backend served it. Removed per the maintainer ruling of 2026-08-09: tombstoned with retiredKey() (the schema is non-strict, so a bare deletion would silently strip what callers still send), registered as RETIRED_KEYS_BY_MAJOR[17] 'data/AggregationNode:distinct' plus the D3 semantic migration 'aggregation-node-distinct-retired'. No D2 conversion — QueryAST is a request surface with no stored source. Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e18a162 commit 3f7f14e

17 files changed

Lines changed: 456 additions & 40 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/objectql': major
4+
---
5+
6+
refactor(spec,objectql)!: retire `AggregationNode.distinct` — one face honoured it, five ignored it, and the same query answered two plausible numbers (#6815, ADR-0049)
7+
8+
<!-- adr-0087: registered aggregation-node-distinct-retired -->
9+
10+
**FROM → TO:** `{ function: 'count', field: 'x', distinct: true, alias: 'a' }`
11+
`{ function: 'count_distinct', field: 'x', alias: 'a' }` — the deduplicating spelling
12+
every backend computes, lowered to `COUNT(DISTINCT x)` on both SQL faces since #6409.
13+
`{ function: 'sum' | 'avg' | 'min' | 'max', …, distinct: true }` → delete the key; there is
14+
no replacement, because no SQL backend ever computed `SUM(DISTINCT …)` here and the
15+
in-memory fallback was the only thing that did. `distinct: false` → delete the key; it
16+
selected the behaviour that is now the only behaviour.
17+
18+
`AggregationNode.distinct` was read by exactly ONE of the six faces that consume an
19+
`aggregations[]` entry. `objectql`'s in-memory fallback (`in-memory-aggregation.ts`)
20+
deduplicated the values before applying the function; `SqlDriver.aggregate`, the Turso
21+
`RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s
22+
`computeAggregate` and `service-analytics`' `AGGREGATE_SQL` all ignored it. So
23+
`{ function: 'sum', field: 'amount', distinct: true }` returned a deduplicated sum when the
24+
engine fell back in memory and an ordinary sum on every SQL datasource — one query, two
25+
numbers, chosen by which backend answered. The engine picks that path per query (a driver
26+
without native aggregation, a non-UTC date bucket, a partial SQL driver), so the number
27+
could move under a dashboard with nothing changing in the query.
28+
29+
That is the divergence class #6203 and #5907 each closed on the aggregate axis, still open
30+
on this key, and it is worse to leave: both answers are plausible NUMBERS rather than a
31+
refusal, so nothing surfaced it. It survived the #4286 sweep of this same schema because
32+
that sweep asked which members no executor reads — the wrong question for a key whose
33+
defect is *which* executor reads it.
34+
35+
REMOVE rather than ENFORCE, per the maintainer ruling of 2026-08-09: `count_distinct`
36+
already covers the only deduplicating spelling with measured demand and took ADR-0049's
37+
enforce leg in #6409, while `SUM(DISTINCT …)` / `AVG(DISTINCT …)` are near-universally a
38+
modelling mistake and would have to be lowered across five faces — two of them frozen under
39+
#5499 — to buy it.
40+
41+
The retirement kit:
42+
43+
- **Tombstone, not deletion** (`retiredKey()`): `AggregationNodeSchema` is not `.strict()`,
44+
so a plain delete would let existing queries parse clean and lose the key in silence
45+
(#3733, ADR-0104) — trading a divergent flag for an ignored one. Authoring it is now a
46+
`tsc` error at the call site and a parse error carrying the prescription. One tombstone
47+
covers every aggregation door: `QuerySchema.aggregations` and
48+
`EngineAggregateOptionsSchema.aggregations` both reuse that one schema by reference.
49+
- **ADR-0087 D3 `SemanticMigration`** (`aggregation-node-distinct-retired`) plus the exact
50+
`RETIRED_KEYS_BY_MAJOR[17]` entry `data/AggregationNode:distinct`. No D2 conversion,
51+
deliberately: `QueryAST` is a request surface — the client SDK builder's output and the
52+
`POST /data/:object/query` body — never stored in stack metadata, so there is no source
53+
for `os migrate meta` to rewrite. That is the disposition every other `data.query.*`
54+
retirement in this major already takes (#4286).
55+
- `objectql`'s in-memory fallback loses its `collectValues` dedupe limb — the whole runtime
56+
cost of the removal. **The observable numbers change on that one path, and that is the
57+
point:** a `sum`/`avg` that used to be deduplicated there now answers what every SQL face
58+
has always answered for the same query. Verify against the SQL answer, not against the
59+
pre-upgrade fallback answer — the two disagreed.
60+
- Measured blast radius inside the fallback, narrower than the key suggests: only `sum` and
61+
`avg` ever changed answer. `count` returned from its own branch before reaching the
62+
dedupe, `count_distinct` fed the values into a `Set` (dedupe-then-`Set` is `Set`), and
63+
dedupe does not move `min`/`max`.
64+
- `POST /api/v1/data/:object/query` answers `400 VALIDATION_FAILED` with a `fields[]` entry
65+
at `aggregations.<i>.distinct` instead of serving a number — the #3899 entry validation
66+
descending into the array, pinned in the REST request-schema conformance gate.
67+
- Liveness ledger (`query.json` `aggregations.children.distinct``dead`, README counts),
68+
generated baselines (`authorable-surface/data.json` gains `[RETIRED]`),
69+
`spec-changes.json`, the upgrade guide and the reference docs regenerated.
70+
71+
`count_distinct` is untouched and remains the live deduplicating spelling.

content/docs/kernel/contracts/data-engine.mdx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,11 +357,16 @@ interface AggregationNode {
357357
function: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct';
358358
field?: string; // Field to aggregate (optional for COUNT(*))
359359
alias: string; // Result column alias
360-
distinct?: boolean; // Apply DISTINCT before aggregation
361360
filter?: FilterCondition; // Per-aggregation FILTER WHERE
362361
}
363362
```
364363

364+
`distinct?: boolean` was **removed** from `AggregationNode` in protocol 17 (#6815,
365+
ADR-0049). Only the engine's in-memory fallback ever honoured it — every SQL face
366+
ignored it — so the same query answered a deduplicated `sum` or an ordinary one
367+
depending on which backend served it. For a deduplicated count use the
368+
`count_distinct` function, which every face computes.
369+
365370
---
366371

367372
## Optional Capabilities

content/docs/protocol/objectql/query-syntax.mdx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,19 @@ a `where` predicate on the sort key — §7), and `distinct` (unique values via
104104
effect was suppressing the REST list count, which is truthful again). **Enforced**:
105105
`having` (§5). The experimental flags above are tracked in the liveness ledger
106106
(`packages/spec/liveness/query.json`).
107+
108+
One member of `AggregationNode` was settled separately, in #6815: the
109+
per-aggregation **`distinct`** flag is **removed** on the same terms. It escaped the
110+
#4286 sweep because that sweep asked which members no executor reads and this one had
111+
a reader — one out of six. The engine's in-memory fallback deduplicated before
112+
applying the function, while `driver-sql`, `driver-turso`, `driver-mongodb`,
113+
`driver-memory` and the analytics SQL builder all ignored it, so
114+
`{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum on
115+
the fallback path and an ordinary sum on every SQL datasource — one query, two
116+
plausible numbers, chosen by which backend served it. The live deduplicating spelling
117+
is the **`count_distinct` function** (`COUNT(DISTINCT field)` on both SQL faces since
118+
#6409); `SUM(DISTINCT …)` / `AVG(DISTINCT …)` have no replacement, because no backend
119+
ever computed them here.
107120
</Callout>
108121

109122
### Key Types
@@ -120,9 +133,9 @@ interface AggregationNode {
120133
function: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct';
121134
field?: string; // optional for COUNT(*)
122135
alias: string; // result column alias
123-
distinct?: boolean; // DISTINCT before aggregation — in-memory path only
124136
filter?: FilterCondition; // [EXPERIMENTAL — not enforced] FILTER WHERE clause — never applied
125137
}
138+
// `distinct?: boolean` was REMOVED in protocol 17 (#6815) — see the callout above.
126139

127140
// FieldNode — one entry of the select list. A field name, optionally dotted to
128141
// reach through a relationship ('owner.name'). Related *records* come from

content/docs/references/api/contract.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ const result = ApiErrorSchema.parse(data);
412412
| **top** | `number` | optional | Alias for limit (OData compatibility) |
413413
| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. |
414414
| **joins** | `never` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand``expand: { owner: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and a single related column is a dotted `fields` path (`fields: ['owner.name']`). |
415-
| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | Aggregation functions |
415+
| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; filter?: any }[]` | optional | Aggregation functions |
416416
| **groupBy** | `(string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string })[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) |
417417
| **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation |
418418
| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. |

content/docs/references/data/data-engine.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,7 @@ QueryAST-aligned options for DataEngine.aggregate operations
467467
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
468468
| **where** | `Record<string, any> \| any` | optional | |
469469
| **groupBy** | `string[]` | optional | |
470-
| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | |
470+
| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; filter?: any }[]` | optional | |
471471
| **having** | `any` | optional | HAVING — filter over the aggregated rows (aggregation aliases + groupBy projections); applied engine-side after aggregation |
472472
| **timezone** | `string` | optional | |
473473

content/docs/references/data/query.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const result = AggregationFunction.parse(data);
4848
| **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` || Aggregation function |
4949
| **field** | `string` | optional | Field to aggregate (optional for COUNT(*)) |
5050
| **alias** | `string` || Result column alias |
51-
| **distinct** | `boolean` | optional | Apply DISTINCT before aggregation |
51+
| **distinct** | `never` | optional | [REMOVED] `query.aggregations[].distinct` was removed in @objectstack/spec 17 (#6815, ADR-0049) — exactly ONE of the six faces that read an aggregation honoured it. The objectql in-memory fallback deduplicated the values before applying the function, while `driver-sql`, `driver-turso`, `driver-mongodb`, `driver-memory` and the service-analytics SQL builder all ignored it — so `{ function: 'sum', field: 'amount', distinct: true }` answered a DEDUPLICATED sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it. Both answers are plausible, so nothing surfaced the divergence. Delete the key. For a deduplicated COUNT the live spelling is the `count_distinct` aggregation function, which every SQL face compiles to `COUNT(DISTINCT field)` (#6409) and the in-memory fallback computes identically. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating is a modelling problem to fix in the data, not a flag on the read. |
5252
| **filter** | `any` | optional | [EXPERIMENTAL — not enforced] Per-aggregation filter (SQL FILTER (WHERE …)). Neither the SQL builders nor the in-memory fallback applies it (#4286); filter the whole query with `where` instead. |
5353

5454

@@ -132,7 +132,7 @@ Type: `string`
132132
| **top** | `number` | optional | Alias for limit (OData compatibility) |
133133
| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. |
134134
| **joins** | `never` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand``expand: { owner: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and a single related column is a dotted `fields` path (`fields: ['owner.name']`). |
135-
| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | Aggregation functions |
135+
| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; filter?: any }[]` | optional | Aggregation functions |
136136
| **groupBy** | `(string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string })[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) |
137137
| **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation |
138138
| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. |

0 commit comments

Comments
 (0)