|
| 1 | +--- |
| 2 | +"@objectstack/spec": major |
| 3 | +"@objectstack/objectql": minor |
| 4 | +"@objectstack/service-analytics": patch |
| 5 | +--- |
| 6 | + |
| 7 | +refactor(spec)!: retire `array_agg` / `string_agg` from `AggregationFunction` — `count_distinct` deliberately kept (#6188, ADR-0049) |
| 8 | + |
| 9 | +`AggregationFunction` declared eight functions; the SQL family compiles five. |
| 10 | +`SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each lower |
| 11 | +`count`/`sum`/`avg`/`min`/`max` and route everything else to one refusal, so |
| 12 | +three of the eight were declared-but-unenforced against the backends this |
| 13 | +platform targets — and, worse, the *set* each backend implemented was different, |
| 14 | +so "which aggregations can I use" had no answer an author could read off the |
| 15 | +schema. |
| 16 | + |
| 17 | +What makes these two sharper than an ordinary inert declaration is that another |
| 18 | +package had to carry a denylist for them. `service-analytics` subtracted |
| 19 | +`array_agg` and `string_agg` by name in `UNSUPPORTED_AGGREGATES`, because |
| 20 | +without that subtraction they reached the Cube strategy's `default` and came |
| 21 | +back as `COUNT(*)` — **a row count in place of the value the author asked for**, |
| 22 | +with no error and no log (objectui#2945). |
| 23 | + |
| 24 | +**The three unlowered functions were SPLIT, not retired as a block** (maintainer |
| 25 | +ruling, 2026-08-07): |
| 26 | + |
| 27 | +- **`count_distinct` STAYS** and takes ADR-0049's *enforce* leg. It is a |
| 28 | + dashboard staple with one portable lowering (`COUNT(DISTINCT x)`), and |
| 29 | + `service-analytics` lowers it already; the SQL-driver implementation follows |
| 30 | + on its own card. Its declaration leads its implementation here by decision, |
| 31 | + not by drift. |
| 32 | +- **`array_agg` / `string_agg` take the *remove* leg.** Display conveniences |
| 33 | + with no measured pull, and `string_agg` never had one shape to lower to at |
| 34 | + all: the delimiter is a second argument in PostgreSQL, a `SEPARATOR` clause in |
| 35 | + MySQL and a differently named function in SQL Server. |
| 36 | + |
| 37 | +FROM → TO, both authoring surfaces: |
| 38 | + |
| 39 | +| Was | Now | |
| 40 | +|:--|:--| |
| 41 | +| `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 | |
| 42 | +| `aggregations: [{ function: 'string_agg', field: 'name', alias: 'names' }]` | as above | |
| 43 | +| `measures: [{ name: 'tags', aggregate: 'array_agg', field: 'tag' }]` | delete the measure — `compileDataset` already refused it by name, so it never produced a number | |
| 44 | + |
| 45 | +The retirement kit: |
| 46 | + |
| 47 | +- This is an enum **VALUE** retirement, so there is no `retiredKey()` tombstone: |
| 48 | + the enum's own error map carries the prescription, keyed on the received value |
| 49 | + so that only the two spellings which used to be legal are told they "were |
| 50 | + removed" (the `crypto.hash` / `HookBodyCapability` precedent, #4391). A |
| 51 | + mis-spelling still gets zod's list of the legal functions. For the same reason |
| 52 | + nothing lands in `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are |
| 53 | + byte-identical — no def and no authorable key changed. |
| 54 | +- **ADR-0087 D2 conversion + D3 chain step** |
| 55 | + (`dataset-measure-array-string-agg-removed`): `os migrate meta --from 16` |
| 56 | + drops any `dataset.measures[]` declaring a retired aggregate, plus any derived |
| 57 | + measure the drop strands, with a notice each. The measure is dropped rather |
| 58 | + than stripped down because one with neither `aggregate` nor `derived` fails |
| 59 | + the dataset's own refinement — a conversion whose output cannot parse is worse |
| 60 | + than none. |
| 61 | +- **D3 semantic entry** (`query-array-string-agg-retired`) for |
| 62 | + `QueryAST.aggregations[].function`: a request surface, never stored, so there |
| 63 | + is no source for the chain to rewrite and callers move their own queries. |
| 64 | +- The engine's in-memory fallback (`@objectstack/objectql`) drops its arms for |
| 65 | + both functions — a `switch` case on a value the enum no longer has does not |
| 66 | + type-check, and a dead arm is how a retired vocabulary returns by accident. |
| 67 | +- `service-analytics`' `UNSUPPORTED_AGGREGATES` is now **empty and kept**: it is |
| 68 | + half of an arithmetic the lockstep suite enforces (`SUPPORTED = spec |
| 69 | + vocabulary − this`), which is what stops the next aggregate added to the spec |
| 70 | + from silently reaching that `COUNT(*)` default. |
| 71 | + |
| 72 | +**Behaviour that actually changes** — this is the rare narrowing that removes |
| 73 | +reachable behaviour, and it is worth stating plainly: on `driver-mongodb` and on |
| 74 | +the engine's in-memory fallback these two DID compute. A raw QueryAST |
| 75 | +aggregation against those backends returned an array or a joined string and will |
| 76 | +now be refused at parse. That unpredictability is precisely what the ruling |
| 77 | +ended — an aggregation that worked on one backend and failed on another is not a |
| 78 | +capability — and both of those backends are inside the #5499 freeze. Their code |
| 79 | +is untouched; it is simply no longer reachable through a spec-valid request. On |
| 80 | +the dataset path nothing changes: `compileDataset` refused both by name already. |
| 81 | + |
| 82 | +<!-- adr-0087: registered query-array-string-agg-retired, dataset-measure-array-string-agg-removed --> |
0 commit comments