diff --git a/.changeset/aggregation-array-string-agg-removed.md b/.changeset/aggregation-array-string-agg-removed.md new file mode 100644 index 0000000000..89664bef85 --- /dev/null +++ b/.changeset/aggregation-array-string-agg-removed.md @@ -0,0 +1,82 @@ +--- +"@objectstack/spec": major +"@objectstack/objectql": minor +"@objectstack/service-analytics": patch +--- + +refactor(spec)!: retire `array_agg` / `string_agg` from `AggregationFunction` — `count_distinct` deliberately kept (#6188, ADR-0049) + +`AggregationFunction` declared eight functions; the SQL family compiles five. +`SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each lower +`count`/`sum`/`avg`/`min`/`max` and route everything else to one refusal, so +three of the eight were declared-but-unenforced against the backends this +platform targets — and, worse, the *set* each backend implemented was different, +so "which aggregations can I use" had no answer an author could read off the +schema. + +What makes these two sharper than an ordinary inert declaration is that another +package had to carry a denylist for them. `service-analytics` subtracted +`array_agg` and `string_agg` by name in `UNSUPPORTED_AGGREGATES`, because +without that subtraction they reached the Cube strategy's `default` and came +back as `COUNT(*)` — **a row count in place of the value the author asked for**, +with no error and no log (objectui#2945). + +**The three unlowered functions were SPLIT, not retired as a block** (maintainer +ruling, 2026-08-07): + +- **`count_distinct` STAYS** and takes ADR-0049's *enforce* leg. It is a + dashboard staple with one portable lowering (`COUNT(DISTINCT x)`), and + `service-analytics` lowers it already; the SQL-driver implementation follows + on its own card. Its declaration leads its implementation here by decision, + not by drift. +- **`array_agg` / `string_agg` take the *remove* leg.** Display conveniences + with no measured pull, and `string_agg` never had one shape to lower to at + all: the delimiter is a second argument in PostgreSQL, a `SEPARATOR` clause in + MySQL and a differently named function in SQL Server. + +FROM → TO, both authoring surfaces: + +| Was | Now | +|:--|:--| +| `aggregations: [{ function: 'array_agg', field: 'tag', alias: 'tags' }]` | no replacement — read the rows with an ordinary `fields` query and shape them in the caller, or materialise the roll-up as a stored field | +| `aggregations: [{ function: 'string_agg', field: 'name', alias: 'names' }]` | as above | +| `measures: [{ name: 'tags', aggregate: 'array_agg', field: 'tag' }]` | delete the measure — `compileDataset` already refused it by name, so it never produced a number | + +The retirement kit: + +- This is an enum **VALUE** retirement, so there is no `retiredKey()` tombstone: + the enum's own error map carries the prescription, keyed on the received value + so that only the two spellings which used to be legal are told they "were + removed" (the `crypto.hash` / `HookBodyCapability` precedent, #4391). A + mis-spelling still gets zod's list of the legal functions. For the same reason + nothing lands in `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are + byte-identical — no def and no authorable key changed. +- **ADR-0087 D2 conversion + D3 chain step** + (`dataset-measure-array-string-agg-removed`): `os migrate meta --from 16` + drops any `dataset.measures[]` declaring a retired aggregate, plus any derived + measure the drop strands, with a notice each. The measure is dropped rather + than stripped down because one with neither `aggregate` nor `derived` fails + the dataset's own refinement — a conversion whose output cannot parse is worse + than none. +- **D3 semantic entry** (`query-array-string-agg-retired`) for + `QueryAST.aggregations[].function`: a request surface, never stored, so there + is no source for the chain to rewrite and callers move their own queries. +- The engine's in-memory fallback (`@objectstack/objectql`) drops its arms for + both functions — a `switch` case on a value the enum no longer has does not + type-check, and a dead arm is how a retired vocabulary returns by accident. +- `service-analytics`' `UNSUPPORTED_AGGREGATES` is now **empty and kept**: it is + half of an arithmetic the lockstep suite enforces (`SUPPORTED = spec + vocabulary − this`), which is what stops the next aggregate added to the spec + from silently reaching that `COUNT(*)` default. + +**Behaviour that actually changes** — this is the rare narrowing that removes +reachable behaviour, and it is worth stating plainly: on `driver-mongodb` and on +the engine's in-memory fallback these two DID compute. A raw QueryAST +aggregation against those backends returned an array or a joined string and will +now be refused at parse. That unpredictability is precisely what the ruling +ended — an aggregation that worked on one backend and failed on another is not a +capability — and both of those backends are inside the #5499 freeze. Their code +is untouched; it is simply no longer reachable through a spec-valid request. On +the dataset path nothing changes: `compileDataset` refused both by name already. + + diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index 23eb5fc120..0d6f8b63da 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -369,16 +369,24 @@ querying the related object directly. | `min` | Minimum value | `{ function: 'min', field: 'price', alias: 'min_price' }` | | `max` | Maximum value | `{ function: 'max', field: 'price', alias: 'max_price' }` | | `count_distinct` | Count unique values | `{ function: 'count_distinct', field: 'category', alias: 'categories' }` | -| `array_agg` | Collect into array | `{ function: 'array_agg', field: 'tag', alias: 'all_tags' }` | -| `string_agg` | Concatenate strings | `{ function: 'string_agg', field: 'name', alias: 'names' }` | -`count_distinct` / `array_agg` / `string_agg` are only fully supported on the MongoDB driver. -The SQL drivers map only `count`/`sum`/`avg`/`min`/`max` and refuse all three as a -**capability gap** — `501 NOT_IMPLEMENTED`, "declared but not implemented by this backend" -— rather than as a caller mistake, because the query is spelled correctly and the gap is -the backend's (#5907). The in-memory driver's aggregator silently returns `null` for them. -Avoid these three on SQL- or memory-backed objects. +`count_distinct` is not yet lowered by the SQL drivers. They map +`count`/`sum`/`avg`/`min`/`max` and refuse it as a **capability gap** — +`501 NOT_IMPLEMENTED`, "declared but not implemented by this backend" — rather than as a +caller mistake, because the query is spelled correctly and the gap is the backend's +(#5907). It works on the MongoDB driver and on the engine's in-memory fallback, and its +SQL lowering (`COUNT(DISTINCT field)`) is scheduled. + + + +**Removed in 17.** `array_agg` and `string_agg` were declared here and compiled by no SQL +backend, which left "what can this backend actually compute" unpredictable to the author. +Both were retired (#6188, ADR-0049 enforce-or-remove): a query carrying either is now +refused at parse with a prescription. There is no replacement in the query vocabulary — +read the rows with an ordinary `fields` query and shape them in the caller, or materialise +the roll-up as a stored field. `os migrate meta --from 16` rewrites affected dataset +measures. diff --git a/content/docs/kernel/contracts/data-engine.mdx b/content/docs/kernel/contracts/data-engine.mdx index d5dffb3b2f..9a4bab0ef6 100644 --- a/content/docs/kernel/contracts/data-engine.mdx +++ b/content/docs/kernel/contracts/data-engine.mdx @@ -354,8 +354,7 @@ interface EngineAggregateOptions { } interface AggregationNode { - function: 'count' | 'sum' | 'avg' | 'min' | 'max' - | 'count_distinct' | 'array_agg' | 'string_agg'; + function: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'; field?: string; // Field to aggregate (optional for COUNT(*)) alias: string; // Result column alias distinct?: boolean; // Apply DISTINCT before aggregation diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index 5cc99dc358..e577c066c7 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -117,8 +117,7 @@ interface SortNode { // AggregationNode — aggregation definition interface AggregationNode { - function: 'count' | 'sum' | 'avg' | 'min' | 'max' - | 'count_distinct' | 'array_agg' | 'string_agg'; + function: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'; field?: string; // optional for COUNT(*) alias: string; // result column alias distinct?: boolean; // DISTINCT before aggregation — in-memory path only @@ -715,17 +714,28 @@ const query: QueryAST = { // { count: 100, total: 5000000, average: 50000, min_amount: 10000, max_amount: 500000 } ``` -**Schema enum:** `count`, `sum`, `avg`, `min`, `max`, `count_distinct`, `array_agg`, `string_agg`. +**Schema enum:** `count`, `sum`, `avg`, `min`, `max`, `count_distinct`. -Only `count`, `sum`, `avg`, `min`, and `max` are portable. `count_distinct`, `array_agg` -and `string_agg` are implemented by the MongoDB driver and by the engine's in-memory -aggregation fallback, but not by the SQL drivers — on `SqlDriver` (and on the Turso -driver, both transports) they are refused as a **capability gap**: +Only `count`, `sum`, `avg`, `min`, and `max` are portable today. `count_distinct` is +declared and is implemented by the MongoDB driver and by the engine's in-memory +aggregation fallback, but not yet by the SQL drivers — on `SqlDriver` (and on the Turso +driver, both transports) it is refused as a **capability gap**: `501 NOT_IMPLEMENTED`, "declared but not implemented by this backend". That is deliberately a different answer from a function the schema enum never declared (`median`), which is `400 INVALID_QUERY` — the caller's mistake — so an author who -wrote `count_distinct` is never told they made a typo (#5907). +wrote `count_distinct` is never told they made a typo (#5907). Its SQL lowering +(`COUNT(DISTINCT field)`) is scheduled: the declaration leads the implementation here +by decision, not by drift. + + + +**Removed in 17:** `array_agg` and `string_agg` were declared by this enum and compiled +by no SQL backend, so which backend could compute them was unpredictable to the author. +Both were retired (#6188, ADR-0049 enforce-or-remove) and are now refused at parse with +a prescription. There is no replacement in the query vocabulary — read the rows with an +ordinary `fields` query and shape them in the caller, or materialise the roll-up as a +stored field. `os migrate meta --from 16` rewrites affected dataset measures. ### Group By Multiple Fields diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 2d9579b2b5..144d9f6f0b 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -412,7 +412,7 @@ const result = ApiErrorSchema.parse(data); | **top** | `number` | optional | Alias for limit (OData compatibility) | | **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. | | **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']`). | -| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | Aggregation functions | +| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | Aggregation functions | | **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) | | **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | | **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`. | diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index 5b36e14c68..07ae177fa0 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -65,7 +65,7 @@ Options for DataEngine.aggregate operations | :--- | :--- | :--- | :--- | | **method** | `'aggregate'` | ✅ | | | **object** | `string` | ✅ | | -| **query** | `{ context?: object; where?: Record \| any; groupBy?: string[]; aggregations?: { function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>; field?: string; alias: string; distinct?: boolean; … }[]; … }` | ✅ | | +| **query** | `{ context?: object; where?: Record \| any; groupBy?: string[]; aggregations?: { function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; distinct?: boolean; … }[]; … }` | ✅ | | --- @@ -328,7 +328,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **method** | `'aggregate'` | ✅ | | | **object** | `string` | ✅ | | -| **query** | `{ context?: object; where?: Record \| any; groupBy?: string[]; aggregations?: { function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>; field?: string; alias: string; distinct?: boolean; … }[]; … }` | ✅ | | +| **query** | `{ context?: object; where?: Record \| any; groupBy?: string[]; aggregations?: { function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; distinct?: boolean; … }[]; … }` | ✅ | | --- @@ -467,7 +467,7 @@ QueryAST-aligned options for DataEngine.aggregate operations | **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | | | **where** | `Record \| any` | optional | | | **groupBy** | `string[]` | optional | | -| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | | +| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | | | **having** | `any` | optional | HAVING — filter over the aggregated rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | | **timezone** | `string` | optional | | diff --git a/content/docs/references/data/query.mdx b/content/docs/references/data/query.mdx index e4504f147e..c22de732ce 100644 --- a/content/docs/references/data/query.mdx +++ b/content/docs/references/data/query.mdx @@ -35,8 +35,6 @@ const result = AggregationFunction.parse(data); * `min` * `max` * `count_distinct` -* `array_agg` -* `string_agg` --- @@ -47,7 +45,7 @@ const result = AggregationFunction.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>` | ✅ | Aggregation function | +| **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | Aggregation function | | **field** | `string` | optional | Field to aggregate (optional for COUNT(*)) | | **alias** | `string` | ✅ | Result column alias | | **distinct** | `boolean` | optional | Apply DISTINCT before aggregation | @@ -134,7 +132,7 @@ Type: `string` | **top** | `number` | optional | Alias for limit (OData compatibility) | | **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. | | **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']`). | -| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | Aggregation functions | +| **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; distinct?: boolean; … }[]` | optional | Aggregation functions | | **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) | | **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | | **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`. | diff --git a/content/docs/references/ui/dataset.mdx b/content/docs/references/ui/dataset.mdx index 265449a2de..3d4e5bbbfc 100644 --- a/content/docs/references/ui/dataset.mdx +++ b/content/docs/references/ui/dataset.mdx @@ -55,7 +55,7 @@ const result = DatasetSchema.parse(data); | **include** | `string[]` | optional | Relationship names/paths to join (derived from object graph; max 3 hops) | | **filter** | `any` | optional | Intrinsic dataset scope filter | | **dimensions** | `{ name: string; label?: string; field: string; type?: Enum<'string' \| 'number' \| 'date' \| 'boolean' \| 'lookup'>; … }[]` | ✅ | Groupable axes | -| **measures** | `{ name: string; label?: string; aggregate?: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>; field?: string; … }[]` | ✅ | Aggregatable values | +| **measures** | `{ name: string; label?: string; aggregate?: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; … }[]` | ✅ | Aggregatable values | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this dataset. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -91,7 +91,7 @@ const result = DatasetSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Measure name — e.g. "revenue"; defined once | | **label** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) | -| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'array_agg' \| 'string_agg'>` | optional | Aggregation (sum/avg/count/...); omit when `derived` is set | +| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | optional | Aggregation (sum/avg/count/...); omit when `derived` is set | | **field** | `string` | optional | Aggregated field; optional for count(*) | | **filter** | `any` | optional | | | **format** | `string` | optional | | diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index 083a8eced1..2c681160c5 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -296,8 +296,6 @@ the widget. Supported functions: | `min` | Minimum value | | `max` | Maximum value | | `count_distinct` | Count unique values | -| `array_agg` | Aggregate values into an array | -| `string_agg` | Concatenate values | ### Widget Layout diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index f1ea8e88f9..8b7b38928b 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -218,6 +218,8 @@ The last of the #4001 enforce-or-remove batch lands on two more `ui/` files (#50 Last, it reconciles the SDUI component-props surface with the renderers that serve it (#5775). #5068 wired the first parse `ComponentPropsMap` ever had, and the corpus it landed on diverged in BOTH directions: keys objectui honours that the schema never declared, and keys the schema declared — one of them REQUIRED — that no renderer reads. The maintainer ruled direction A (2026-08-06), the #5611 rule again: the delivered and authorized shape is the contract. So the honoured keys are declared (`element:record_picker` `labelField`/`valueField`/`label`/`emptyText`, `record:path` `stages[].terminal`, `page:tabs` `items[].value`/`items[].count`, `page:card` `children`, and `children` on `page:section`/`page:footer`/`page:sidebar`, which were declared `EmptyProps` while their renderers rendered a child list), and four keys retire. Two are synonym renames: `element:record_picker.displayField` → `labelField` (the required key no renderer read, while `labelField ?? 'name'` is what actually renders the row — so an author who followed the schema got a picker listing `name` with no diagnostic, the ADR-0078 shape), and `page:card.body` → `children` (one composition key across every container; the card renderer already reads both, and the showcase authors `children`). Two are enforce-or-remove deletions: `element:record_picker.searchFields` and `.multiple` — the control is a shadcn single-select with no search input, binding ONE record id into a page variable, so `searchFields` narrowed nothing and `multiple: true` selected nothing extra while reporting success. Either returns the day the capability is implemented (#5021 / #4988). Not in scope, and deliberately: `page:card.visible` is a component-level visibility predicate written into `properties` and hoisted by the renderer — a page to rewrite onto the ADR-0089 `visibleWhen`, not a key to declare. +Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave `AggregationFunction` (#6188, ADR-0049). The enum declared eight functions and the SQL family compiles five — `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each lower `count`/`sum`/`avg`/`min`/`max` and route the rest to one refusal — so three were declared-but-unenforced against the backends this platform targets. What makes these two worse than an ordinary inert declaration is that another package had to carry a denylist for them: `service-analytics` subtracted `array_agg` and `string_agg` by name in `UNSUPPORTED_AGGREGATES`, because without that subtraction they reached the Cube strategy's `default` and returned `COUNT(*)` — a row count in place of the requested value, with no error and no log. The maintainer SPLIT the three rather than retiring them as a block (2026-08-07), and the split is the point: `count_distinct` STAYS and takes the enforce leg — one portable lowering (`COUNT(DISTINCT x)`), a dashboard staple, already lowered by `service-analytics` — with its SQL implementation following on its own card, so that declaration leads its implementation by decision rather than by drift. These two take the remove leg: display conveniences with no measured pull, and `string_agg` never had one shape to lower to (the delimiter is a second argument in PostgreSQL, a `SEPARATOR` clause in MySQL, a differently named function in SQL Server). This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed on the received value so only the two spellings that used to be legal are told they "were removed". Of the two authoring surfaces only one is stored metadata: the conversion rewrites `dataset.measures[].aggregate`, dropping the measure outright (a measure with neither `aggregate` nor `derived` fails the dataset's own refinement, so stripping just the key would emit an item that cannot parse) plus any derived measure the drop strands, with a notice each. Nothing is lost: `compileDataset` refused both by name already, so such a measure never produced a number. `QueryAST.aggregations[].function` is a request surface with no stored source — one semantic TODO below. The mongodb and in-memory backends that implemented these two are inside the #5499 freeze and are untouched; their code is simply no longer reachable through a spec-valid request. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -262,6 +264,7 @@ Last, it reconciles the SDUI component-props surface with the renderers that ser | `object-managed-by-system-to-system-data` | `object.managedBy` | object managedBy 'system' → 'system-data' (#3355 — ADR-0103's residual bucket named the engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the name describing what the bucket actually holds: admin/user-writable platform data) | retired — `migrate meta` only | | `object-enable-trash-mru-removed` | `object.enable.trash / object.enable.mru` | object capability flags 'enable.trash'/'enable.mru' removed (#3207, #2377 close-out — no recycle bin and no MRU tracking ever ran; both default-true flags gated nothing) | retired — `migrate meta` only | | `hook-body-crypto-hash-removed` | `hook.body.capabilities / action.body.capabilities` | script-body capability token 'crypto.hash' removed (#4391 — the sandbox never installed ctx.crypto.hash, so the token granted a call that always threw; the CLI inferred it too) | retired — `migrate meta` only | +| `dataset-measure-array-string-agg-removed` | `dataset.measures[].aggregate` | dataset measure aggregates 'array_agg' / 'string_agg' removed (#6188 — no SQL backend compiled them and the v1 dataset runtime refused them by name, so a measure declaring one never produced a value; the measure is dropped, and with it any derived measure left referencing it) | retired — `migrate meta` only | | `connector-rate-limit-config-removed` | `connector.rateLimitConfig` | connector key 'rateLimitConfig' removed (#4911 — no outbound rate-limiting engine exists; the runtime's only token bucket limits INBOUND requests, so every knob here was inert while reading like a configured cap. The whole ConnectorRateLimitConfig shape went with it) | retired — `migrate meta` only | | `field-mapping-transform-removed` | `connector.fieldMappings[].transform / externalLookup.fieldMappings[].transform` | field-mapping key 'transform' removed (#5552 — the whole five-member FieldMappingTransform union went with it: no runtime ever executed constant/cast/lookup/javascript/map, and the javascript member advertised dialect="js", retired in #3278. The enforced transform pipeline is the import mapping's string-enum `mapping.fieldMapping[].transform`, which is unaffected) | retired — `migrate meta` only | | `theme-inert-token-scales-removed` | `theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight / theme.typography.letterSpacing / theme.typography.fontFamily.heading / theme.typography.fontFamily.mono / theme.animation / theme.zIndex` | theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', 'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed (#5021, ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono faithfully, and no first-party component or stylesheet has ever read one. Re-declare any variable you actually consume under customVars, which emits it verbatim) | retired — `migrate meta` only | @@ -315,6 +318,9 @@ Last, it reconciles the SDUI component-props surface with the renderers that ser - **`query-distinct-retired`** — `data.query.distinct` → `groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values - Why not automatic: The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that "confirmed" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286. - Done when: No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; deduplication goes through `groupBy` / `count_distinct` / the drivers' `distinct()` door. A query still carrying the key fails to parse with the removal prescription, and the REST list response reports a real `total` for queries that used to send it. +- **`query-array-string-agg-retired`** — `data.query.aggregations[].function ('array_agg' / 'string_agg')` → an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared + - Why not automatic: The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188. + - Done when: No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style roll-ups are assembled by the caller from an ordinary `fields` query, or materialised as a stored field. A query still carrying either value fails to parse with the removal prescription naming it, and authoring it is a `tsc` error at the call site; `count_distinct` continues to parse and is unaffected. - **`workflow-service-slot-retired`** — `CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow` → the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation - Why not automatic: The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451. - Done when: No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/Transition types resolves; no code calls getService('workflow') or reads discovery `routes.workflow` / `services.workflow`; record state machines, approvals and record-triggered automation go through the replacement mechanisms. Discovery output on a default boot is unchanged (the slot was always reported unavailable; now it is simply absent). diff --git a/packages/drivers/driver-sql/src/sql-driver-out-of-contract-aggregate-function.test.ts b/packages/drivers/driver-sql/src/sql-driver-out-of-contract-aggregate-function.test.ts index 79864c7746..5bb5163f27 100644 --- a/packages/drivers/driver-sql/src/sql-driver-out-of-contract-aggregate-function.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-out-of-contract-aggregate-function.test.ts @@ -154,7 +154,16 @@ describe('[#5907] SqlDriver refuses an aggregate function it cannot compile', () describe('a function name the Query Protocol never declared', () => { // `median` is the issue's own repro. The rest are the names a SQL-fluent // author reaches for that `AggregationFunction` does not declare. - const UNDECLARED = ['median', 'stddev', 'percentile_cont', 'group_concat']; + // + // `array_agg` / `string_agg` MOVED HERE from class 2 at #6188: the ruling + // retired both from `AggregationFunction`, so they are no longer "declared + // but not compiled by this backend" (501) — they are names the protocol + // does not declare (400), exactly like `group_concat` beside them. Keeping + // them covered on this side of the split is the point: the answer to the + // same input changed, and this is where that change is legible. + const UNDECLARED = [ + 'median', 'stddev', 'percentile_cont', 'group_concat', 'array_agg', 'string_agg', + ]; for (const fn of UNDECLARED) { it(`refuses "${fn}" with INVALID_QUERY / 400`, async () => { @@ -195,19 +204,25 @@ describe('[#5907] SqlDriver refuses an aggregate function it cannot compile', () // ── Class 2: declared by the protocol, not compiled by this backend ──────── describe('a DECLARED function this backend cannot compile', () => { - // Exactly the three `AggregationFunction` declares with no SQL lowering. - // The TYPE is load-bearing (#4918): `AggregationNode['function']` is the - // declared enum, so a typo in this fixture — or a name that leaves the enum - // when #6188 is decided — fails `tsc` instead of quietly becoming a class-1 - // input that still passes a class-2 assertion for the wrong reason. + // Exactly what `AggregationFunction` declares with no SQL lowering — one + // name since #6188. The TYPE is load-bearing (#4918): + // `AggregationNode['function']` is the declared enum, so a typo in this + // fixture — or a name that LEAVES the enum — fails `tsc` instead of quietly + // becoming a class-1 input that still passes a class-2 assertion for the + // wrong reason. That is exactly how #6188 announced itself here: retiring + // `array_agg` / `string_agg` turned those two entries into TS2322 rather + // than leaving them asserting 501 for names the protocol no longer has. + // + // `count_distinct` was deliberately NOT retired with them (maintainer + // ruling, 2026-08-07): it takes ADR-0049's enforce leg, and lowering it to + // `COUNT(DISTINCT x)` in this driver is its own card. Until that lands it is + // the whole of class 2, and this suite is what keeps its refusal honest. const UNCOMPILABLE: Array = [ 'count_distinct', - 'array_agg', - 'string_agg', ]; // Guard: the fixture is the real declared-minus-compiled set, derived rather - // than trusted. If the spec drops one (that decision is #6188) or this driver + // than trusted. If the spec drops one (as #6188 dropped two) or this driver // implements one, this fails HERE rather than leaving a case that passes // because nothing is produced. it('the fixture is exactly the declared-but-uncompiled set', () => { diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index a02f35a9ab..651f44184d 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -567,14 +567,22 @@ function undeclaredAggregateFunctionError(func: string): Error { * [#5907] Class 2 — a DECLARED function this backend cannot compile. * * Distinct from {@link undeclaredAggregateFunctionError} on purpose, and this is - * the half that must not be collapsed into it: `count_distinct`, `array_agg` and - * `string_agg` are declared by `AggregationFunction` and implemented by other - * backends (`driver-mongodb` compiles all three, `driver-memory`'s analytics - * face compiles `count_distinct`), so telling a dashboard author their + * the half that must not be collapsed into it: `count_distinct` is declared by + * `AggregationFunction` and implemented by other backends (`driver-mongodb`, + * and `driver-memory`'s analytics face), so telling a dashboard author their * `count_distinct` is a typo would be false — the same line #5345 drew in * `driver-memory`'s `filter-refusal.ts` between `unknownFieldOperatorError` and * `uncompilableFieldOperatorError`. * + * `array_agg` and `string_agg` used to belong to this class too. #6188 retired + * both from `AggregationFunction` (ADR-0049 — declared by the spec, compiled by + * no SQL backend), so they now fall to class 1 and answer 400: the protocol no + * longer has those names, which is a different fact from "this backend cannot + * lower them" and deserves the different answer. `count_distinct` was + * deliberately kept and takes the enforce leg instead — lowering it here to + * `COUNT(DISTINCT x)` is its own card, and until that lands it is the only + * inhabitant of this class. + * * `NOT_IMPLEMENTED` / 501 is the answer, from the ADR-0112 STANDARD catalog * ("Feature not yet implemented"), whose own `HttpStatusErrorCodeMap` pairs it * with 501 — so code and status are each other's mirror by construction rather diff --git a/packages/drivers/driver-turso/src/remote-transport-aggregate-function-refusal.test.ts b/packages/drivers/driver-turso/src/remote-transport-aggregate-function-refusal.test.ts index 2df61256a2..3305efd1c4 100644 --- a/packages/drivers/driver-turso/src/remote-transport-aggregate-function-refusal.test.ts +++ b/packages/drivers/driver-turso/src/remote-transport-aggregate-function-refusal.test.ts @@ -190,7 +190,14 @@ async function localRefusalOf(fn: string, ast: QueryAST): Promise { describe('a function name the Query Protocol never declared', () => { - const UNDECLARED = ['median', 'stddev', 'percentile_cont', 'group_concat']; + // `array_agg` / `string_agg` moved here from the class below at #6188, which + // retired both from `AggregationFunction` — they are now undeclared names + // (400), not declared-but-uncompiled ones (501). Mirrors the twin in + // `driver-sql`; the parity block further down compares the two faces on the + // reclassified pair as well. + const UNDECLARED = [ + 'median', 'stddev', 'percentile_cont', 'group_concat', 'array_agg', 'string_agg', + ]; for (const fn of UNDECLARED) { it(`refuses "${fn}" with INVALID_QUERY / 400`, async () => { @@ -228,10 +235,11 @@ describe('[#5907] RemoteTransport refuses an aggregate function it cannot compil describe('a DECLARED function this backend cannot compile', () => { // Typed against the declared enum on purpose — see the twin's note (#4918). + // One name since #6188 retired the other two; `count_distinct` stays + // declared (it takes ADR-0049's enforce leg) and is therefore the whole of + // this class until its SQL lowering lands on its own card. const UNCOMPILABLE: Array = [ 'count_distinct', - 'array_agg', - 'string_agg', ]; it('the fixture is exactly the declared-but-uncompiled set', () => { @@ -261,13 +269,21 @@ describe('[#5907] RemoteTransport refuses an aggregate function it cannot compil // literal — a shared constant would agree with itself no matter how far the // two faces drifted. This is what makes "首句逐字一致" checkable. // One entry per class, each carrying the query value its class is entitled - // to: `median` cannot be a `QueryAST` (that is what class 1 means), the three - // declared names can and are. + // to: `median` cannot be a `QueryAST` (that is what class 1 means), the + // declared name can and is. + // + // `array_agg` / `string_agg` stay in this list across #6188 and change + // SIDES: they were class-2 fixtures built with `declaredAst`, and now that + // the enum no longer has them they are class-1 fixtures built with + // `undeclaredAst`. Parity is the property that must survive the + // reclassification — the two faces have to agree on the NEW answer as + // exactly as they agreed on the old one, which is what would break if only + // one of them read the narrowed enum. const PARITY: Array<[fn: string, ast: QueryAST]> = [ ['median', undeclaredAst('median')], ['count_distinct', declaredAst('count_distinct')], - ['array_agg', declaredAst('array_agg')], - ['string_agg', declaredAst('string_agg')], + ['array_agg', undeclaredAst('array_agg')], + ['string_agg', undeclaredAst('string_agg')], ]; for (const [fn, ast] of PARITY) { it(`"${fn}" is answered identically by the local driver and this transport`, async () => { diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index 25eb2f33c1..e68d857e3a 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -569,10 +569,16 @@ function undeclaredAggregateFunctionError(func: string): Error { * * The twin of `driver-sql`'s `uncompilableAggregateFunctionError`; its docblock * carries the full rationale for `NOT_IMPLEMENTED` / 501 and for why the two - * classes must not be collapsed. In one line: `count_distinct`, `array_agg` and - * `string_agg` are declared and other backends compile them, so a caller who - * wrote one has made no mistake — this is the backend's gap, and an error that - * says otherwise tells a dashboard author to fix a query that is already right. + * classes must not be collapsed. In one line: `count_distinct` is declared and + * other backends compile it, so a caller who wrote it has made no mistake — + * this is the backend's gap, and an error that says otherwise tells a dashboard + * author to fix a query that is already right. + * + * `array_agg` / `string_agg` left this class at #6188, which answered the + * question the message below points at: they were retired from + * `AggregationFunction` rather than implemented, so they are now undeclared + * names and answer 400. `count_distinct` took the other leg of that ruling and + * is, until its SQL lowering lands, the whole of this class. */ function uncompilableAggregateFunctionError(func: string): Error { const err = new Error( diff --git a/packages/objectql/src/in-memory-aggregation.test.ts b/packages/objectql/src/in-memory-aggregation.test.ts index 50bf4e8884..8cdb74a834 100644 --- a/packages/objectql/src/in-memory-aggregation.test.ts +++ b/packages/objectql/src/in-memory-aggregation.test.ts @@ -61,19 +61,37 @@ describe('applyInMemoryAggregation', () => { expect(eastQ1!.n).toBe(2); }); - it('honours count_distinct + array_agg + string_agg', () => { + it('honours count_distinct', () => { const out = applyInMemoryAggregation(rows, { groupBy: ['region'], aggregations: [ { function: 'count_distinct', field: 'owner', alias: 'owners' }, - { function: 'array_agg', field: 'owner', alias: 'owner_list' }, - { function: 'string_agg', field: 'owner', alias: 'owner_str' }, ], }); const east = out.find((r) => r.region === 'East'); expect(east!.owners).toBe(2); - expect(east!.owner_list).toEqual(['alice', 'alice', 'bob']); - expect(east!.owner_str).toBe('alice,alice,bob'); + }); + + // #6188 retired `array_agg` / `string_agg` from `AggregationFunction`, and + // this fallback's arms for them went with the vocabulary. The case is kept — + // re-spelled onto what the retirement actually guarantees — because the pair + // reached this path from a spec-valid request until v17, so "the fallback no + // longer computes them" is the observable half of the change. It arrives as + // `null` from the `default` arm, not as an array; callers cannot reach this + // through a parsed query at all, since the enum refuses both by name. + it('no longer computes the retired list aggregations', () => { + const out = applyInMemoryAggregation(rows, { + groupBy: ['region'], + // Cast: these are exactly the values `AggregationFunction` no longer has, + // which is what this test exists to pin. + aggregations: [ + { function: 'array_agg', field: 'owner', alias: 'owner_list' }, + { function: 'string_agg', field: 'owner', alias: 'owner_str' }, + ] as never, + }); + const east = out.find((r) => r.region === 'East'); + expect(east!.owner_list).toBeNull(); + expect(east!.owner_str).toBeNull(); }); // #3839 — this used to be the literal string `'(null)'`, which the pushed-down diff --git a/packages/objectql/src/in-memory-aggregation.ts b/packages/objectql/src/in-memory-aggregation.ts index a065d2d964..c5d267049f 100644 --- a/packages/objectql/src/in-memory-aggregation.ts +++ b/packages/objectql/src/in-memory-aggregation.ts @@ -12,8 +12,14 @@ // * Flat groupBy strings: `['region']` // * Structured groupBy with date bucketing: `[{ field: 'closed_at', // dateGranularity: 'quarter' }]` -// * Aggregation functions: count, count_distinct, sum, avg, min, max, -// array_agg, string_agg +// * Aggregation functions: count, count_distinct, sum, avg, min, max — +// the whole of `AggregationFunction`. This fallback used to implement two +// more, `array_agg` and `string_agg`, which #6188 retired from the spec +// vocabulary (ADR-0049: no SQL backend ever compiled them, so which +// backend could compute what was unpredictable to the author). Their arms +// are deleted rather than left unreachable — a `switch` case on a value +// the enum no longer has does not type-check, and dead arms are how a +// retired vocabulary comes back by accident. // * `distinct: true` on aggregations (collapse duplicates before applying // the function) // * `filter: FilterCondition` on aggregations is **not** evaluated here — @@ -188,12 +194,6 @@ function aggregateBucket(rows: any[], aggregations: AggregationNode[]): Record (a > b ? a : b)); break; } - case 'array_agg': - out[alias] = values.slice(); - break; - case 'string_agg': - out[alias] = values.filter((v) => v != null).map(String).join(','); - break; default: out[alias] = null; } diff --git a/packages/rest/src/analytics-dataset-refusal-envelope.test.ts b/packages/rest/src/analytics-dataset-refusal-envelope.test.ts index 52bfce27d3..79c8429cf9 100644 --- a/packages/rest/src/analytics-dataset-refusal-envelope.test.ts +++ b/packages/rest/src/analytics-dataset-refusal-envelope.test.ts @@ -153,11 +153,35 @@ describe('[#5367] a dataset refusal answers 400 DATASET_INVALID from its own env body: unknown; analytics: () => AnalyticsService; message: RegExp; + /** Defaults to `DATASET_INVALID`; see the one row that legitimately differs. */ + code?: string; }> = [ { - name: 'dataset-compiler: an aggregate the v1 runtime cannot lower', + /* + * ⚠️ This row's ANSWER changed at #6188, and the change is the finding — + * measured on this route, not predicted. + * + * The only inputs that ever reached `dataset-compiler`'s aggregate refusal + * were `array_agg` and `string_agg`; #6188 retired both from + * `AggregationFunction`, so the route's own `DatasetSchema.parse` now + * refuses this body one layer earlier. Measured before/after on this + * route: `400 DATASET_INVALID` → `400 VALIDATION_FAILED`. The status is + * unchanged — the caller still gets a 4xx naming their own mistake — but + * the producer, and therefore the code, is the schema now. + * + * Kept rather than deleted, with the successor answer pinned: the row's + * `listEntry` is the audit trail for one entry #5367 removed from the + * route's message list, and that trail is what the coverage test below + * checks. What this row can no longer prove is that the route envelopes a + * COMPILER aggregate refusal — that branch is unreachable end to end while + * `UNSUPPORTED_AGGREGATES` is empty. Rows ②–⑤ still carry that guarantee + * for the compiler, executor and strategy; saying so is more useful than + * synthesising an aggregate to keep the old shape alive. + */ + name: 'dataset schema: an aggregate retired from the vocabulary (#6188)', listEntry: 'not supported by the v1 dataset runtime', analytics: aggregateAnalytics, + code: 'VALIDATION_FAILED', body: { dataset: { ...dataset, @@ -165,7 +189,11 @@ describe('[#5367] a dataset refusal answers 400 DATASET_INVALID from its own env }, selection: { dimensions: ['stage'], measures: ['names'] }, }, - message: /measure "names" uses aggregate "string_agg" which is not supported by the v1 dataset runtime/, + // ⚠️ The route replaces the message with a generic + // "Invalid dataset definition." and carries the parse error in `detail`, + // so this row asserts the generic message here and the prescription + // below — where it actually lands. + message: /Invalid dataset definition\./, }, { name: 'dataset-compiler: a dimension traversing an undeclared relationship path', @@ -208,12 +236,12 @@ describe('[#5367] a dataset refusal answers 400 DATASET_INVALID from its own env ]; for (const c of CASES) { - it(`${c.name} → 400 DATASET_INVALID`, async () => { + it(`${c.name} → 400 ${c.code ?? 'DATASET_INVALID'}`, async () => { const route = buildRoute(async () => c.analytics()); const res = await post(route, c.body); expect(res.statusCode).toBe(400); - expect(res.body.code).toBe('DATASET_INVALID'); + expect(res.body.code).toBe(c.code ?? 'DATASET_INVALID'); // The message survives intact so the author can act on it, and the body is // the 4xx shape (`message`), not the 5xx one (`error`). expect(String(res.body.message)).toMatch(c.message); @@ -234,6 +262,43 @@ describe('[#5367] a dataset refusal answers 400 DATASET_INVALID from its own env ]); }); + /* + * [#6188] Where the retirement prescription actually lands on this route — + * measured, not assumed, because the answer has a known cut in it. + * + * The route replaces every parse failure's `message` with a generic + * "Invalid dataset definition." and puts the real one in `detail`, capped at + * 1000 characters (`rest-server.ts`). The prescription's zod-wrapped message + * is 1076 chars for `array_agg` and 1304 for `string_agg`, so the two clauses + * an author must act on — "was removed" (~index 192) and the imperative + * "Delete the aggregation" (690 / 918) — arrive, and the closing + * `os migrate meta --from 16` sentence is TRUNCATED away on this surface + * only. It is intact everywhere the cap does not apply: the `tsc` error, a + * direct `DatasetSchema.parse`, and `os validate`. + * + * Pinned rather than papered over. Shortening the prescription to fit one + * route's cap would degrade it on every surface that has no cap, and raising + * the cap (or returning the issues structurally) is a REST-lane decision + * about every long prescription, not this vocabulary's to make. + */ + it('the retirement prescription reaches the HTTP caller in `detail`, minus the truncated tail', async () => { + const route = buildRoute(async () => aggregateAnalytics()); + const res = await post(route, { + dataset: { ...dataset, measures: [{ name: 'names', aggregate: 'string_agg', field: 'name' }] }, + selection: { dimensions: ['stage'], measures: ['names'] }, + }); + + expect(res.statusCode).toBe(400); + const detail = String(res.body.detail); + expect(detail).toContain('`string_agg`'); + expect(detail).toContain('was removed'); + expect(detail).toContain('Delete the aggregation'); + // The cap's measured consequence — asserted so that raising the cap, or + // shortening the message, shows up here as a deliberate change. + expect(detail.length).toBe(1000); + expect(detail).not.toContain('os migrate meta'); + }); + it('POSITIVE control (aggregate path): the same wiring, a valid selection → 200 with rows', async () => { const route = buildRoute(async () => aggregateAnalytics()); const res = await post(route, { dataset, selection }); diff --git a/packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts b/packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts index 0c2fcfc438..c57b0f35f4 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts @@ -98,15 +98,32 @@ describe('compileDataset', () => { expect(cube.joins?.account?.name).toBe('account'); }); - it('rejects v1-unsupported aggregates with a clear error', () => { - const ds = DatasetSchema.parse({ + // Was: "rejects v1-unsupported aggregates with a clear error", driven by + // `array_agg` reaching `compileDataset`. #6188 retired `array_agg` and + // `string_agg` from `AggregationFunction`, so that input cannot be built any + // more — the refusal moved from this compiler to the schema, and it now + // carries a prescription instead of naming the supported list. Re-pointed + // rather than deleted: the dataset measure is one of the retirement's two + // authoring surfaces, and this is where that surface is exercised. + it('rejects a retired aggregate at the schema, with the retirement prescription', () => { + expect(() => DatasetSchema.parse({ name: 'agg', label: 'Agg', object: 'opportunity', dimensions: [], measures: [{ name: 'tags', aggregate: 'array_agg', field: 'tag' }], + })).toThrowError(/`array_agg`.*was removed.*#6188/s); + }); + + it('still compiles the aggregate the ruling kept', () => { + const ds = DatasetSchema.parse({ + name: 'agg', + label: 'Agg', + object: 'opportunity', + dimensions: [], + measures: [{ name: 'tags', aggregate: 'count_distinct', field: 'tag' }], }); - expect(() => compileDataset(ds)).toThrowError(/not supported by the v1 dataset runtime/); + expect(compileDataset(ds).cube.measures.tags?.type).toBe('count_distinct'); }); }); diff --git a/packages/services/service-analytics/src/__tests__/dataset-refusal-envelope.test.ts b/packages/services/service-analytics/src/__tests__/dataset-refusal-envelope.test.ts index e86becff67..13e0786177 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-refusal-envelope.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-refusal-envelope.test.ts @@ -55,7 +55,7 @@ import type { IAnalyticsService, StrategyContext, } from '@objectstack/spec/contracts'; -import { compileDataset } from '../dataset-compiler.js'; +import { compileDataset, UNSUPPORTED_AGGREGATES } from '../dataset-compiler.js'; import { DatasetExecutor, resolveOrdering } from '../dataset-executor.js'; import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js'; import { compileScopedFilterToSql } from '../read-scope-sql.js'; @@ -141,17 +141,29 @@ const REFUSALS: Array<{ { name: '① dataset-compiler: aggregate outside the v1 runtime', listEntry: 'not supported by the v1 dataset runtime', - message: /measure "names" uses aggregate "string_agg" which is not supported by the v1 dataset runtime/, - run: () => - compileDataset( - DatasetSchema.parse({ + message: /measure "names" uses aggregate "probe_agg" which is not supported by the v1 dataset runtime/, + // This row used to be driven by `string_agg`, a real spec value this + // runtime could not lower. #6188 retired it (and `array_agg`) from + // `AggregationFunction`, so the refusal moved one layer earlier — no + // spec-valid dataset reaches this branch any more, and the input can no + // longer go through `DatasetSchema.parse`. The branch itself stays, as the + // landing site for the next aggregate declared ahead of this runtime, and + // so does this row: what it pins is the ENVELOPE, which is this file's + // subject and is independent of which value happens to be unlowered today. + run: () => { + UNSUPPORTED_AGGREGATES.add('probe_agg'); + try { + return compileDataset({ name: 'pipeline', label: 'Pipeline', object: 'crm_opportunity', dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], - measures: [{ name: 'names', aggregate: 'string_agg', field: 'name' }], - }), - ), + measures: [{ name: 'names', aggregate: 'probe_agg', field: 'name' }], + } as never); + } finally { + UNSUPPORTED_AGGREGATES.delete('probe_agg'); + } + }, }, { name: '② dataset-compiler: field traversing an undeclared relationship path', diff --git a/packages/services/service-analytics/src/aggregation-lockstep.test.ts b/packages/services/service-analytics/src/aggregation-lockstep.test.ts index cb2f88cf6c..a7f8b55299 100644 --- a/packages/services/service-analytics/src/aggregation-lockstep.test.ts +++ b/packages/services/service-analytics/src/aggregation-lockstep.test.ts @@ -61,29 +61,58 @@ describe('aggregate vocabulary lockstep', () => { it('records the current split, so a vocabulary change is visible in review', () => { expect([...SUPPORTED_AGGREGATES].sort()) .toEqual(['avg', 'count', 'count_distinct', 'max', 'min', 'sum']); - expect([...UNSUPPORTED_AGGREGATES].sort()).toEqual(['array_agg', 'string_agg']); + // Empty since #6188: the rejection list's two members (`array_agg`, + // `string_agg`) were retired from the spec instead, so the refusal moved + // one layer earlier — to the parse, where it carries a prescription. The + // split is now "everything declared is lowered", which is the state + // ADR-0049 asks for; this line is what makes a regression away from it + // visible in review. + expect([...UNSUPPORTED_AGGREGATES].sort()).toEqual([]); + }); + + it('the spec no longer declares the two aggregates this runtime refused', () => { + // The other direction of the same fact, asserted against the spec rather + // than against our subtraction list — so re-adding either upstream fails + // here even if someone also re-adds it to `UNSUPPORTED_AGGREGATES` and + // keeps the partition arithmetic balanced. + expect(AggregationFunction.options as string[]).not.toContain('array_agg'); + expect(AggregationFunction.options as string[]).not.toContain('string_agg'); + // Kept deliberately (maintainer ruling 2026-08-07): it takes ADR-0049's + // enforce leg, and this compiler already lowers it. + expect(AggregationFunction.options as string[]).toContain('count_distinct'); }); }); describe('the compiler error message is derived, not restated', () => { it('names every supported aggregate, and none of the unsupported ones', async () => { const { compileDataset } = await import('./dataset-compiler.js'); + + // #6188 emptied `UNSUPPORTED_AGGREGATES`, so no real value reaches the + // refusal branch any more. The branch is still the landing site for the + // next aggregate the spec declares ahead of this runtime, so the probe is + // injected rather than dropped — testing the message's DERIVATION, which + // is what this suite was written for, instead of the retired member that + // happened to trigger it. + UNSUPPORTED_AGGREGATES.add('probe_agg'); let message = ''; try { compileDataset({ name: 'agg_probe', object: 'showcase_task', dimensions: [{ name: 'status', field: 'status', type: 'string' }], - measures: [{ name: 'names', field: 'title', aggregate: 'string_agg' }], + measures: [{ name: 'names', field: 'title', aggregate: 'probe_agg' }], } as never); } catch (e) { message = (e as Error).message; + } finally { + UNSUPPORTED_AGGREGATES.delete('probe_agg'); } - expect(message).toContain('string_agg'); + expect(message).toContain('probe_agg'); for (const a of SUPPORTED_AGGREGATES) { expect(message, `error message omits supported aggregate "${a}"`).toContain(a); } - // The prose list it replaced would have kept claiming these are supported. - expect(message).not.toContain('array_agg,'); + // The prose list it replaced would have kept claiming the refused one is + // supported; the derived message names it only as the rejected value. + expect(message).not.toContain('probe_agg,'); }); }); diff --git a/packages/services/service-analytics/src/dataset-compiler.ts b/packages/services/service-analytics/src/dataset-compiler.ts index e476ae7a3b..b73a726382 100644 --- a/packages/services/service-analytics/src/dataset-compiler.ts +++ b/packages/services/service-analytics/src/dataset-compiler.ts @@ -22,8 +22,26 @@ import { datasetInvalidError } from './dataset-refusal.js'; * enforces at SQL-build time. */ -/** Operators v1 does NOT compile to the Cube SQL switch — surfaced as a clear error. */ -export const UNSUPPORTED_AGGREGATES = new Set(['array_agg', 'string_agg']); +/** + * Aggregates v1 does NOT compile to the Cube SQL switch — surfaced as a clear error. + * + * **EMPTY since #6188, and deliberately kept.** It named `array_agg` and + * `string_agg`: the two aggregates the spec declared and this runtime could not + * lower. ADR-0049 resolved that the honest way round — both were retired from + * `AggregationFunction` itself, so they are now refused one layer earlier, by + * the parse, with a prescription that tells the author what to do instead. + * `count_distinct` was the third unlowered function on the SQL drivers and was + * NOT retired (maintainer ruling, 2026-08-07): this compiler lowers it already, + * and the driver-side implementation follows on its own card. + * + * The set stays because it is one half of an arithmetic the lockstep tests + * enforce (`SUPPORTED = spec vocabulary − this`), and that arithmetic is what + * stops the next aggregate added to the spec from reaching the strategy's + * `default` and returning a row count in place of the requested value. Empty is + * the correct current reading — every declared aggregate is lowered — not a + * leftover. + */ +export const UNSUPPORTED_AGGREGATES = new Set(); /** * What v1 *can* lower — derived from the spec's vocabulary rather than restated. @@ -137,6 +155,13 @@ function aggregateToMetricType(m: DatasetMeasure): Metric['type'] { if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) { // [#5367] `DATASET_INVALID` / 400 — the aggregate is the dataset author's // choice, and the message already names the ones that would work. + // + // Unreachable while `UNSUPPORTED_AGGREGATES` is empty (#6188 retired its two + // members from the spec, which now refuses them at parse). Kept as the + // landing site for the next aggregate the spec declares before this runtime + // can lower it: without it that aggregate reaches the strategy's `default` + // and comes back as a row count. The lockstep suite is what decides which + // of the two states we are in, so this branch cannot rot unnoticed. throw datasetInvalidError( `[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is ` + `not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(', ')}).`, diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index 8356e10bac..0a287fc680 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -20,9 +20,10 @@ import { nextUtcCalendarDay } from '@objectstack/core'; * * A table rather than a `switch` so its coverage is *assertable*: the aggregate * vocabulary lives in `@objectstack/spec` (`AggregationFunction`), the dataset - * compiler subtracts the two it cannot lower (`array_agg`, `string_agg`), and - * `aggregation-lockstep.test.ts` checks that what remains is exactly the keys - * below. A `switch` gave that no purchase — the missing case fell to + * compiler subtracts whatever it cannot lower (`UNSUPPORTED_AGGREGATES` — empty + * since #6188 retired its two members, `array_agg` and `string_agg`, from the + * spec itself), and `aggregation-lockstep.test.ts` checks that what remains is + * exactly the keys below. A `switch` gave that no purchase — the missing case fell to * `default: COUNT(*)`, so an aggregate the spec grew would have returned a row * count instead of the number the author asked for, silently. objectui#2945. * diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 159be5a8eb..feef335809 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -308,6 +308,12 @@ "conversionId": "hook-body-crypto-hash-removed", "toMajor": 17 }, + { + "surface": "dataset.measures[].aggregate", + "to": "dataset measure aggregates 'array_agg' / 'string_agg' removed (#6188 — no SQL backend compiled them and the v1 dataset runtime refused them by name, so a measure declaring one never produced a value; the measure is dropped, and with it any derived measure left referencing it)", + "conversionId": "dataset-measure-array-string-agg-removed", + "toMajor": 17 + }, { "surface": "connector.rateLimitConfig", "to": "connector key 'rateLimitConfig' removed (#4911 — no outbound rate-limiting engine exists; the runtime's only token bucket limits INBOUND requests, so every knob here was inert while reading like a configured cap. The whole ConnectorRateLimitConfig shape went with it)", @@ -526,6 +532,13 @@ "toMajor": 17, "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286." }, + { + "surface": "data.query.aggregations[].function ('array_agg' / 'string_agg')", + "replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared", + "migrationId": "query-array-string-agg-retired", + "toMajor": 17, + "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188." + }, { "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow", "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation", @@ -1088,6 +1101,12 @@ "conversionId": "hook-body-crypto-hash-removed", "toMajor": 17 }, + { + "surface": "dataset.measures[].aggregate", + "to": "dataset measure aggregates 'array_agg' / 'string_agg' removed (#6188 — no SQL backend compiled them and the v1 dataset runtime refused them by name, so a measure declaring one never produced a value; the measure is dropped, and with it any derived measure left referencing it)", + "conversionId": "dataset-measure-array-string-agg-removed", + "toMajor": 17 + }, { "surface": "connector.rateLimitConfig", "to": "connector key 'rateLimitConfig' removed (#4911 — no outbound rate-limiting engine exists; the runtime's only token bucket limits INBOUND requests, so every knob here was inert while reading like a configured cap. The whole ConnectorRateLimitConfig shape went with it)", @@ -1236,6 +1255,13 @@ "toMajor": 17, "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286." }, + { + "surface": "data.query.aggregations[].function ('array_agg' / 'string_agg')", + "replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared", + "migrationId": "query-array-string-agg-retired", + "toMajor": 17, + "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188." + }, { "surface": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow", "replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 2b0581262d..749c332d65 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -4045,6 +4045,160 @@ const hookBodyCryptoHashRemoved: MetadataConversion = { }, }; +/** + * `array_agg` / `string_agg` leave `AggregationFunction` (protocol 17, #6188 — + * ADR-0049 enforce-or-remove). + * + * The enum declared eight functions; the SQL family compiles five. + * `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each + * lower `count`/`sum`/`avg`/`min`/`max` and route everything else to the same + * refusal, and `service-analytics` carried a hand-written `UNSUPPORTED_AGGREGATES` + * list naming exactly these two — a subtraction that existed to stop them + * reaching the Cube strategy's `default`, which returned `COUNT(*)`: a row count + * in place of the number the author asked for. So the declaration was not merely + * unenforced, it was the reason another package had to carry a denylist. + * + * The maintainer's 2026-08-07 ruling SPLIT the three unlowered functions rather + * than retiring them as a block, and the split is the substance of this entry. + * `count_distinct` stays: one portable lowering (`COUNT(DISTINCT x)`), a + * dashboard staple, and `service-analytics` already lowers it — so it takes + * ADR-0049's ENFORCE leg and the SQL implementation follows on its own card. + * These two take the REMOVE leg: display conveniences with no measured pull, + * and `string_agg` has no single shape to lower to at all (the delimiter is a + * second argument in PostgreSQL, a `SEPARATOR` clause in MySQL, a differently + * named function in SQL Server). + * + * Like `hook-body-crypto-hash-removed` above, this is an enum-VALUE retirement: + * there is no `retiredKey()` tombstone to hang the prescription on, so the enum's + * own error map carries it (`ARRAY_AGG_RETIRED` / `STRING_AGG_RETIRED`, + * `data/query.zod.ts`), keyed on `issue.input` so only the two spellings that + * used to be legal are told they "were removed". For the same reason nothing + * lands in `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are expected to + * be byte-identical — no def and no authorable KEY changed. + * + * ## What this rewrites, and what it deliberately does not + * + * The retired values are authorable in two places and only one of them is stored + * metadata: + * + * - **`dataset.measures[].aggregate`** (`ui/dataset.zod.ts`, reusing this enum) + * is carried in the stack, so it is what this conversion walks. + * - **`QueryAST.aggregations[].function`** is a REQUEST surface — the client + * SDK's builder output and the `POST /data/:object/query` body, never stored + * (`liveness/query.json` records the same fact for `joins`/`cursor`/`distinct`). + * There is no source for the chain to rewrite, so it is a semantic TODO on the + * D3 step instead. + * + * The measure is DROPPED rather than stripped down to a bare `{ name, field }`. + * A measure with no `aggregate` and no `derived` fails the dataset's own + * `superRefine`, so stripping the key alone would hand back an item that cannot + * parse — a conversion whose output is invalid is worse than no conversion. And + * nothing is lost by dropping it: `compileDataset` has always refused these two + * by name with `datasetInvalidError`, so a stored dataset carrying one never + * produced a number on any backend. Every drop emits its own notice, so + * `os migrate meta` names the measure it removed rather than quietly shrinking + * the dataset. + * + * The cascade is part of that correctness, not extra: a `derived` measure whose + * `of` names a dropped measure would leave the dataset failing the "derived + * measures may only reference OTHER measures declared in this dataset" refinement. + * It is applied to a fixpoint because a derived measure may combine other derived + * measures. + * + * `retiredFromLoadPath`: the enum rejects both values outright, so a live author + * is taught at parse rather than silently rewritten. The entry exists so stored + * 16.x/17-rc rows replay clean (`applyConversionsToStoredItem` — without it a + * pre-removal row flags `metadata_spec_invalid` forever, mislabelling + * chain-owned history as a current-contract violation) and so + * `os migrate meta --from 16` rewrites author sources. + */ +const datasetMeasureAggRemoved: MetadataConversion = { + id: 'dataset-measure-array-string-agg-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'dataset.measures[].aggregate', + summary: + "dataset measure aggregates 'array_agg' / 'string_agg' removed (#6188 — no SQL backend " + + 'compiled them and the v1 dataset runtime refused them by name, so a measure declaring ' + + 'one never produced a value; the measure is dropped, and with it any derived measure ' + + 'left referencing it)', + apply(stack, emit) { + const RETIRED = new Set(['array_agg', 'string_agg']); + return mapCollection(stack, 'datasets', (dataset, path) => { + const measures = dataset.measures; + if (!Array.isArray(measures)) return dataset; + + const dropped = new Set(); + const kept = measures.filter((m, i) => { + if (!isDict(m)) return true; + const aggregate = m.aggregate; + if (typeof aggregate !== 'string' || !RETIRED.has(aggregate)) return true; + emit({ from: aggregate, to: '(removed)', path: `${path}.measures[${i}].aggregate` }); + if (typeof m.name === 'string') dropped.add(m.name); + return false; + }); + if (kept.length === measures.length) return dataset; + + // Fixpoint: a derived measure may be built from another derived measure, + // so one pass can strand a reference the next pass has to answer for. + let survivors = kept; + for (;;) { + const next = survivors.filter((m) => { + if (!isDict(m)) return true; + const derived = m.derived; + if (!isDict(derived) || !Array.isArray(derived.of)) return true; + if (!derived.of.some((name) => typeof name === 'string' && dropped.has(name))) return true; + emit({ + from: `derived measure "${String(m.name)}"`, + to: '(removed)', + path: `${path}.measures`, + }); + if (typeof m.name === 'string') dropped.add(m.name); + return false; + }); + if (next.length === survivors.length) break; + survivors = next; + } + + return { ...dataset, measures: survivors }; + }); + }, + fixture: { + before: { + datasets: [{ + name: 'order_lines', + object: 'order_line', + dimensions: [{ name: 'status', field: 'status', type: 'string' }], + measures: [ + { name: 'total_amount', aggregate: 'sum', field: 'amount' }, + { name: 'product_ids', aggregate: 'array_agg', field: 'product_id' }, + { name: 'product_names', aggregate: 'string_agg', field: 'product_name' }, + // Derived measures: the first is stranded by the drop above, the + // second is stranded by the first — the reason the sweep runs to a + // fixpoint rather than once. + { name: 'name_list', derived: { op: 'sum', of: ['product_names'] } }, + { name: 'name_list_ratio', derived: { op: 'ratio', of: ['name_list', 'total_amount'] } }, + // Survives: derived from measures that are all still here. + { name: 'amount_share', derived: { op: 'ratio', of: ['total_amount', 'total_amount'] } }, + ], + }], + }, + after: { + datasets: [{ + name: 'order_lines', + object: 'order_line', + dimensions: [{ name: 'status', field: 'status', type: 'string' }], + measures: [ + { name: 'total_amount', aggregate: 'sum', field: 'amount' }, + { name: 'amount_share', derived: { op: 'ratio', of: ['total_amount', 'total_amount'] } }, + ], + }], + }, + // Two retired aggregates, plus the two derived measures the drops stranded. + expectedNotices: 4, + }, +}; + /** * `connector.rateLimitConfig` — OUTBOUND throttling for an engine that does not * exist (#4911, ADR-0049). @@ -4837,6 +4991,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { it('should accept valid aggregation functions', () => { const validFunctions = [ 'count', 'sum', 'avg', 'min', 'max', - 'count_distinct', 'array_agg', 'string_agg' + 'count_distinct' ]; validFunctions.forEach(fn => { @@ -19,10 +19,52 @@ describe('AggregationFunction', () => { }); }); + it('declares exactly the functions the SQL family can lower', () => { + // The roster itself is the contract (#6188): `service-analytics` derives + // its supported set from `.options`, so an addition here silently widens + // what the dataset compiler claims to support. A change to this list is a + // vocabulary decision and must be visible in review. + expect(AggregationFunction.options).toEqual([ + 'count', 'sum', 'avg', 'min', 'max', 'count_distinct', + ]); + }); + it('should reject invalid aggregation functions', () => { expect(() => AggregationFunction.parse('COUNT')).toThrow(); expect(() => AggregationFunction.parse('median')).toThrow(); }); + + // ── #6188: the two retired values carry a prescription, not a bare reject ── + // + // Both halves matter. The refusal alone is what `median` already got; what + // the retirement adds is that the author who wrote a value which USED to be + // legal is told it was removed and what to do instead. A test that only + // asserted `.toThrow()` would stay green if the error map were deleted. + it('prescribes the retirement for `array_agg`', () => { + expect(() => AggregationFunction.parse('array_agg')) + .toThrow(/`array_agg`.*was removed.*#6188.*Delete the aggregation/s); + }); + + it('prescribes the retirement for `string_agg`', () => { + expect(() => AggregationFunction.parse('string_agg')) + .toThrow(/`string_agg`.*was removed.*#6188.*Delete the aggregation/s); + }); + + it('does NOT tell a mis-spelling that it "was removed"', () => { + // `arry_agg` was never legal; claiming it was removed would misinform. + // Only the two retired spellings are dispatched — everything else keeps + // zod's own enum message listing the legal functions. + expect(() => AggregationFunction.parse('arry_agg')).toThrow(); + const message = (() => { + try { + AggregationFunction.parse('arry_agg'); + return ''; + } catch (e) { + return (e as Error).message; + } + })(); + expect(message).not.toContain('was removed'); + }); }); describe('QuerySchema - Basic', () => { @@ -454,8 +496,14 @@ describe('QuerySchema - Aggregations', () => { expect(() => QuerySchema.parse(query)).not.toThrow(); }); - it('should accept query with ARRAY_AGG aggregation', () => { - const query: QueryAST = { + // These two used to assert that `array_agg` / `string_agg` PARSE. #6188 + // retired both, so the fixtures are replaced rather than re-spelled: what + // needs pinning now is that the refusal reaches the author through the + // nested `aggregations[]` parse, carrying the prescription — the enum's + // error map is reachable from the real authoring surface, not just from a + // bare `AggregationFunction.parse`. + it('refuses ARRAY_AGG through the query surface, with the prescription', () => { + const query = { object: 'order', fields: ['customer_id'], aggregations: [ @@ -464,11 +512,12 @@ describe('QuerySchema - Aggregations', () => { groupBy: ['customer_id'], }; - expect(() => QuerySchema.parse(query)).not.toThrow(); + expect(() => QuerySchema.parse(query)) + .toThrow(/`array_agg`.*was removed.*os migrate meta/s); }); - it('should accept query with STRING_AGG aggregation', () => { - const query: QueryAST = { + it('refuses STRING_AGG through the query surface, with the prescription', () => { + const query = { object: 'order', fields: ['customer_id'], aggregations: [ @@ -477,6 +526,23 @@ describe('QuerySchema - Aggregations', () => { groupBy: ['customer_id'], }; + expect(() => QuerySchema.parse(query)) + .toThrow(/`string_agg`.*was removed.*os migrate meta/s); + }); + + it('still accepts the aggregation that was kept', () => { + // `count_distinct` takes ADR-0049's ENFORCE leg (maintainer ruling + // 2026-08-07), so the split must be visible from the authoring surface: + // one of the three unlowered functions stayed and two left. + const query: QueryAST = { + object: 'order', + fields: ['customer_id'], + aggregations: [ + { function: 'count_distinct', field: 'product_id', alias: 'products' }, + ], + groupBy: ['customer_id'], + }; + expect(() => QuerySchema.parse(query)).not.toThrow(); }); diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index 47b89627bf..ee672b0825 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -65,10 +65,39 @@ export const SortNodeSchema = lazySchema(() => strictObject( }, )); +// Retired-VALUE prescriptions. Declared with `//` (never `/** */`) and ABOVE the +// enum's JSDoc deliberately: `build-docs.ts` takes a file's FIRST JSDoc as the +// reference page's blurb, so a doc comment here would displace the function +// table below. (The `crypto.hash` / `HookBodyCapability` precedent, +// `data/hook-body.zod.ts`, which is the other enum-value retirement in tree.) +const AGG_RETIRED_MIDDLE = + ' was removed from `AggregationFunction` in @objectstack/spec 17 (#6188, ADR-0049 ' + + 'enforce-or-remove) — no SQL backend ever compiled it. `SqlDriver.mapAggregateFunc` and ' + + '`RemoteTransport.aggregate` each lower the same five functions and refuse the rest, and ' + + "the v1 dataset runtime had to subtract this one by name to stop it reaching a `COUNT(*)` " + + 'fallback that returns a row count in place of the value asked for. On the backend family ' + + 'this platform targets it was a declaration that could only fail. '; +const AGG_RETIRED_TAIL = + 'There is no replacement in the query vocabulary: read the rows with an ordinary `fields` ' + + 'query and shape them in the caller, or model the roll-up as a stored field. It returns ' + + 'only WITH a portable lowering — ADR-0049\'s enforce leg, implementation first. ' + + 'Run `os migrate meta --from 16` to rewrite it automatically.'; + +const ARRAY_AGG_RETIRED = + '`array_agg`' + AGG_RETIRED_MIDDLE + + 'Delete the aggregation, or the dataset measure that declared it. ' + AGG_RETIRED_TAIL; + +const STRING_AGG_RETIRED = + '`string_agg`' + AGG_RETIRED_MIDDLE + + 'Its dialect divergence is the widest of the family — the delimiter is a second argument ' + + 'in PostgreSQL, a `SEPARATOR` clause in MySQL and a differently-named function in SQL ' + + 'Server — so there was never one shape to lower it to. Delete the aggregation, or the ' + + 'dataset measure that declared it. ' + AGG_RETIRED_TAIL; + /** * Aggregation Function Enum * Standard aggregation functions for data analysis. - * + * * Supported Functions: * - **count**: Count rows (SQL: COUNT(*) or COUNT(field)) * - **sum**: Sum numeric values (SQL: SUM(field)) @@ -76,9 +105,17 @@ export const SortNodeSchema = lazySchema(() => strictObject( * - **min**: Minimum value (SQL: MIN(field)) * - **max**: Maximum value (SQL: MAX(field)) * - **count_distinct**: Count unique values (SQL: COUNT(DISTINCT field)) - * - **array_agg**: Aggregate values into array (SQL: ARRAY_AGG(field)) - * - **string_agg**: Concatenate values (SQL: STRING_AGG(field, delimiter)) - * + * + * `array_agg` and `string_agg` were REMOVED in 17 (#6188). They were declared + * here from the day the enum was written and compiled by no SQL backend, while + * `service-analytics` carried a hand-written subtraction list naming exactly + * these two. `count_distinct` is deliberately kept on the other side of that + * split (maintainer ruling, 2026-08-07): a dashboard staple with one portable + * lowering (`COUNT(DISTINCT x)`), it takes ADR-0049's ENFORCE leg — the SQL + * implementation is its own card and the declaration stays ahead of it by + * decision, not by drift. Authoring a retired value is a `tsc` error and a + * parse error carrying the prescription above. + * * Performance Considerations: * - COUNT(*) is typically faster than COUNT(field) as it doesn't check for nulls * - COUNT DISTINCT may require additional memory for tracking unique values @@ -107,8 +144,19 @@ export const SortNodeSchema = lazySchema(() => strictObject( */ export const AggregationFunction = z.enum([ 'count', 'sum', 'avg', 'min', 'max', - 'count_distinct', 'array_agg', 'string_agg' -]); + 'count_distinct' +], { + // Only the two spellings that USED to be legal get the retirement message. + // Telling the author of `arry_agg` that their value "was removed" would + // misinform, so everything else keeps zod's own enum error, which already + // lists the legal functions. (`crypto.hash`, and the `managedBy: 'system'` + // precedent one level up in object.zod.ts.) + error: (issue) => { + if (issue.input === 'array_agg') return ARRAY_AGG_RETIRED; + if (issue.input === 'string_agg') return STRING_AGG_RETIRED; + return undefined; + }, +}); /** * Date Granularity Enum diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index b396bba5a7..27d06827ae 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1099,7 +1099,36 @@ const step17: MigrationStep = { + 'selected nothing extra while reporting success. Either returns the day the capability ' + 'is implemented (#5021 / #4988). Not in scope, and deliberately: `page:card.visible` is a ' + 'component-level visibility predicate written into `properties` and hoisted by the ' - + 'renderer — a page to rewrite onto the ADR-0089 `visibleWhen`, not a key to declare.', + + 'renderer — a page to rewrite onto the ADR-0089 `visibleWhen`, not a key to declare.\n\n' + + 'Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave ' + + '`AggregationFunction` (#6188, ADR-0049). The enum declared eight functions and the SQL ' + + 'family compiles five — `SqlDriver.mapAggregateFunc` and the Turso ' + + '`RemoteTransport.aggregate` each lower `count`/`sum`/`avg`/`min`/`max` and route the rest ' + + 'to one refusal — so three were declared-but-unenforced against the backends this platform ' + + 'targets. What makes these two worse than an ordinary inert declaration is that another ' + + 'package had to carry a denylist for them: `service-analytics` subtracted `array_agg` and ' + + '`string_agg` by name in `UNSUPPORTED_AGGREGATES`, because without that subtraction they ' + + "reached the Cube strategy's `default` and returned `COUNT(*)` — a row count in place of " + + 'the requested value, with no error and no log. The maintainer SPLIT the three rather than ' + + 'retiring them as a block (2026-08-07), and the split is the point: `count_distinct` STAYS ' + + 'and takes the enforce leg — one portable lowering (`COUNT(DISTINCT x)`), a dashboard ' + + 'staple, already lowered by `service-analytics` — with its SQL implementation following on ' + + 'its own card, so that declaration leads its implementation by decision rather than by ' + + 'drift. These two take the remove leg: display conveniences with no measured pull, and ' + + '`string_agg` never had one shape to lower to (the delimiter is a second argument in ' + + 'PostgreSQL, a `SEPARATOR` clause in MySQL, a differently named function in SQL Server). ' + + 'This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no ' + + '`retiredKey()` tombstone: the enum error map carries the prescription, keyed on the ' + + 'received value so only the two spellings that used to be legal are told they "were ' + + 'removed". Of the two authoring surfaces only one is stored metadata: the conversion ' + + 'rewrites `dataset.measures[].aggregate`, dropping the measure outright (a measure with ' + + 'neither `aggregate` nor `derived` fails the dataset\'s own refinement, so stripping just ' + + 'the key would emit an item that cannot parse) plus any derived measure the drop strands, ' + + 'with a notice each. Nothing is lost: `compileDataset` refused both by name already, so ' + + 'such a measure never produced a number. `QueryAST.aggregations[].function` is a request ' + + 'surface with no stored source — one semantic TODO below. The mongodb and in-memory ' + + 'backends that implemented these two are inside the #5499 freeze and are untouched; their ' + + 'code is simply no longer reachable through a spec-valid request.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -1149,6 +1178,7 @@ const step17: MigrationStep = { 'record-picker-display-field-to-label-field', 'record-picker-inert-keys-removed', 'page-card-body-to-children', + 'dataset-measure-array-string-agg-removed', ], semantic: [ { @@ -1440,6 +1470,35 @@ const step17: MigrationStep = { + 'door. A query still carrying the key fails to parse with the removal prescription, ' + 'and the REST list response reports a real `total` for queries that used to send it.', }, + { + id: 'query-array-string-agg-retired', + surface: "data.query.aggregations[].function ('array_agg' / 'string_agg')", + replacement: + 'an ordinary `fields` query, shaped in the caller — or a stored field that materialises ' + + 'the roll-up. For a deduplicated COUNT the live spelling is unchanged: ' + + '`count_distinct` stays declared', + reason: + 'The stored half of this retirement is a conversion ' + + '(`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. ' + + '`QueryAST` is never stored in stack metadata — it is the client SDK builder\'s output ' + + 'and the `POST /data/:object/query` body — so there is no source for the chain to ' + + 'rewrite and callers move their own queries. Both values were declared-but-unlowered ' + + 'on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso ' + + '`RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller ' + + 'following the schema against a SQL datasource got a refusal, not an array. They did ' + + 'run on `driver-mongodb` and on the engine\'s in-memory fallback, which is what makes ' + + 'this the one narrowing in the batch that removes reachable behaviour: an aggregation ' + + 'that worked on one backend and failed on another is exactly the unpredictability the ' + + 'ruling ended, and #5499 has both of those backends frozen. `count_distinct` was ' + + 'deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049\'s ' + + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188.', + acceptanceCriteria: + 'No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style ' + + 'roll-ups are assembled by the caller from an ordinary `fields` query, or materialised ' + + 'as a stored field. A query still carrying either value fails to parse with the ' + + 'removal prescription naming it, and authoring it is a `tsc` error at the call site; ' + + '`count_distinct` continues to parse and is unaffected.', + }, { id: 'workflow-service-slot-retired', surface: diff --git a/packages/spec/src/shared/enums.zod.ts b/packages/spec/src/shared/enums.zod.ts index 2d782681bd..570539923b 100644 --- a/packages/spec/src/shared/enums.zod.ts +++ b/packages/spec/src/shared/enums.zod.ts @@ -13,9 +13,10 @@ import { lazySchema } from './lazy-schema'; // no importer in this repo, objectui, or cloud — while `AggregationFunction` // (`data/query.zod.ts`) is the vocabulary the query engine, dataset compiler and // native-SQL strategy all gate on. The two even disagreed: this one had -// percentile/median/stddev/variance, that one has array_agg/string_agg. Removed -// rather than reconciled, because a second name for one concept is how the -// vocabularies drifted apart in the first place. objectui#2945. +// percentile/median/stddev/variance, that one carried array_agg/string_agg +// (themselves retired at #6188, ADR-0049 — no SQL backend compiled them). +// Removed rather than reconciled, because a second name for one concept is how +// the vocabularies drifted apart in the first place. objectui#2945. /** Sort direction used across query, data-engine, analytics */ export const SortDirectionEnum = z.enum(['asc', 'desc']) diff --git a/packages/spec/src/ui/chart.zod.ts b/packages/spec/src/ui/chart.zod.ts index 9479e28aeb..50ec8c2c84 100644 --- a/packages/spec/src/ui/chart.zod.ts +++ b/packages/spec/src/ui/chart.zod.ts @@ -716,9 +716,11 @@ export const ChartConfigSchema = lazySchema(() => strictObject( * * A deliberate subset of the engine's `AggregationFunction`: these are the five * the chart renderers implement in every path, including the client-side - * fallback. `count_distinct` / `array_agg` / `string_agg` are engine-level - * capabilities with no chart renderer behind them — advertising them here would - * be a declared-but-not-delivered claim (Prime Directive #10). + * fallback. `count_distinct` is the one engine-level function left outside the + * subset — no chart renderer computes it, and advertising it here would be a + * declared-but-not-delivered claim (Prime Directive #10). `array_agg` and + * `string_agg` were named here for the same reason until #6188 retired them + * from the engine vocabulary outright; the subset is unchanged by that. */ export const ChartAggregateFunctionSchema = lazySchema(() => z.enum(['count', 'sum', 'avg', 'min', 'max']), diff --git a/skills/objectstack-query/SKILL.md b/skills/objectstack-query/SKILL.md index f3dcb68893..072d5d1a09 100644 --- a/skills/objectstack-query/SKILL.md +++ b/skills/objectstack-query/SKILL.md @@ -330,15 +330,19 @@ unique or near-unique column such as `created_at` or `id`) so | `min` | Minimum | `MIN(field)` | | `max` | Maximum | `MAX(field)` | | `count_distinct` | Unique count | `COUNT(DISTINCT field)` | -| `array_agg` | Collect into array | `ARRAY_AGG(field)` | -| `string_agg` | Concatenate strings | `STRING_AGG(field, ',')` | > ⚠️ **Driver support varies.** On SQL datasources the driver executes only -> `count` / `sum` / `avg` / `min` / `max` and **throws** on `count_distinct`, -> `array_agg`, and `string_agg`; the per-aggregation `distinct: true` flag is -> also ignored there. The in-memory fallback path (driver-rest, driver-memory, -> timezone/date-bucket fallbacks) supports all 8 functions plus `distinct`. -> For portable queries, stick to the first five. +> `count` / `sum` / `avg` / `min` / `max` and **throws** on `count_distinct`; +> the per-aggregation `distinct: true` flag is also ignored there. The +> in-memory fallback path (driver-rest, driver-memory, timezone/date-bucket +> fallbacks) supports all six functions plus `distinct`. For portable queries, +> stick to the first five. + +> **Removed in 17 (#6188).** `array_agg` and `string_agg` left this vocabulary: +> declared but lowered by no SQL backend, so whether they worked depended on +> which driver sat behind the object. Either one is now refused at parse. There +> is no replacement — read the rows with an ordinary `fields` query and shape +> them in the caller, or materialise the roll-up as a stored field. ### GroupBy + Aggregation diff --git a/skills/objectstack-query/rules/aggregation.md b/skills/objectstack-query/rules/aggregation.md index 531e60f8b4..56f10e4946 100644 --- a/skills/objectstack-query/rules/aggregation.md +++ b/skills/objectstack-query/rules/aggregation.md @@ -12,16 +12,20 @@ Guide for building ObjectStack aggregation queries. | `min` | `MIN(field)` | Minimum value | Yes | | `max` | `MAX(field)` | Maximum value | Yes | | `count_distinct` | `COUNT(DISTINCT field)` | Count unique values | Yes | -| `array_agg` | `ARRAY_AGG(field)` | Collect values into array | Yes | -| `string_agg` | `STRING_AGG(field, ',')` | Concatenate string values | Yes | > ⚠️ **Driver support varies.** On SQL datasources the driver executes only > `count` / `sum` / `avg` / `min` / `max` and **throws** (`Unsupported -> aggregate function`) on `count_distinct`, `array_agg`, and `string_agg`; -> the per-aggregation `distinct: true` flag is also ignored there. The -> in-memory aggregation path (driver-rest, driver-memory, timezone/ -> date-bucket fallbacks) supports all 8 functions plus `distinct`. For -> portable queries, stick to the first five. +> aggregate function`) on `count_distinct`; the per-aggregation +> `distinct: true` flag is also ignored there. The in-memory aggregation path +> (driver-rest, driver-memory, timezone/date-bucket fallbacks) supports all six +> functions plus `distinct`. For portable queries, stick to the first five. + +> **Removed in 17 (#6188).** `array_agg` and `string_agg` are no longer part of +> the vocabulary — they were declared and lowered by no SQL backend, so a query +> using them succeeded or failed depending on which driver happened to be +> behind the object. A query carrying either is refused at parse. There is no +> replacement: read the rows with an ordinary `fields` query and shape them in +> the caller, or materialise the roll-up as a stored field. ## Basic Aggregation diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index ce1e1086e3..a8ff9de44b 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -602,7 +602,7 @@ escalate to. Decide on **expressibility**; reuse/governance is Level B. |:--|:--| | one base object + **to-one** joins (`include`, ≤3 hops) | a join that **changes grain** / a **to-many** rollup onto the parent | | 0..N dimensions; date-bucket `day/week/month/quarter/year` | a **computed dimension** / CASE bucket / numeric bin | -| measures `count/sum/avg/min/max/count_distinct` | `array_agg`/`string_agg` or any custom-SQL metric | +| measures `count/sum/avg/min/max/count_distinct` | list aggregation (collect-into-array / concatenate — retired at #6188, no spelling exists) or any custom-SQL metric | | **derived measures** — `ratio/sum/difference/product` of other measures | scalar math on raw fields (`amount*0.8`), aggregate-of-aggregate | | WHERE (`$and/$or/$not` on the base object) + measure-scoped filters | **HAVING** (filtering the aggregate result) | | `compareTo` (previous period/year) + `totals` (matrix subtotals) | **window** (rank, running total, lag/lead, %-of-total); **union**; reshaping params |