|
| 1 | +--- |
| 2 | +"@objectstack/service-analytics": minor |
| 3 | +"@objectstack/spec": minor |
| 4 | +--- |
| 5 | + |
| 6 | +feat(service-analytics)!: `min` and `max` are judged by the aggregate × field-type table too — all 74 refused pairs answer `400 DATASET_INVALID` through one compile door (#17560) |
| 7 | + |
| 8 | +<!-- adr-0087: registered dataset-measure-selecting-aggregate-field-type-refused --> |
| 9 | + |
| 10 | +**BREAKING** — an accept-set narrowing on a published authoring surface, and the last |
| 11 | +one this table owed. A dataset measure pairing `aggregate: 'min'` (or `'max'`) with any |
| 12 | +of the **37** field types outside the numeric, temporal and boolean classes — for example |
| 13 | +`text`, `select`, `lookup`, `autonumber`, `json`, `multiselect`, `file`, `location`, |
| 14 | +`vector` or `formula`; the ADR-0087 entry registered below carries the full list — used to |
| 15 | +compile and reach the backend; it is now refused by |
| 16 | +`compileDataset` with `DATASET_INVALID` / **400** before any query is built. Shipped as |
| 17 | +`minor` under the repo's launch-window convention for accept-set narrowings. |
| 18 | + |
| 19 | +⛔ This changeset adds no rows to any table and restates none. The verdict is |
| 20 | +`AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in |
| 21 | +#16353 under the director ruling of decision batch #59 ("both legs, table in spec") — |
| 22 | +read through `isAggregateCompatibleWithFieldType`. |
| 23 | + |
| 24 | +## What was wrong |
| 25 | + |
| 26 | +The table refused these 74 pairs from the day it was declared, and **four declarations |
| 27 | +gave three different answers about them**: |
| 28 | + |
| 29 | +| declaration | what it said about `min` × `text` | |
| 30 | +|---|---| |
| 31 | +| `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (spec) | refused | |
| 32 | +| `dataset-compiler`'s compile leg | never judged — `if (!DERIVING_AGGREGATES.has(aggregate)) return;` | |
| 33 | +| `measureResultType` (service-analytics, #15768) | a supported `'string'` result | |
| 34 | +| two shipped test files, in prose | "ruled C — the table is to be AMENDED to accept it" | |
| 35 | + |
| 36 | +Driven through the real service door before anything was written, `min` / `max` over 13 |
| 37 | +sampled refused pairs all compiled and emitted SQL, with `avg` × `datetime` as the |
| 38 | +firing control (refused, `DATASET_INVALID` / 400, no SQL) — so the zero was a reading of |
| 39 | +the tree rather than of a blind harness. |
| 40 | + |
| 41 | +The fourth row had nothing behind it. The card it cited (#17513) is closed as a |
| 42 | +duplicate carrying zero rulings, and the one recorded ruling on this table says the |
| 43 | +opposite. ⇒ The director ruling of decision batch #127 (2026-09-13) settled all three |
| 44 | +sub-questions in one pass, because one shared fixture drove members of both halves: |
| 45 | + |
| 46 | +1. **the string classes** (42 pairs) stay refused, as batch #59 ruled — ⛔ the table is |
| 47 | + not amended; |
| 48 | +2. **the non-string classes** (32 pairs) are refused **and enforced**; |
| 49 | +3. **`formula`** is refused on the table's own storage ground — it is VIRTUAL in SQL |
| 50 | + storage, no column is emitted, so no aggregate can be lowered to it whatever |
| 51 | + `returnType` says. |
| 52 | + |
| 53 | +The divergence is real, and for these two aggregates it is the **ORDER** rather than the |
| 54 | +arithmetic: string order is collation-dependent, so two backends answer two different |
| 55 | +"smallest" values for one metadata document, and `min(jsonb)` does not exist on |
| 56 | +PostgreSQL at all. |
| 57 | + |
| 58 | +## What changed |
| 59 | + |
| 60 | +- **`dataset-compiler`**: the scope condition is gone. `assertAggregateFieldTypeCompatible` |
| 61 | + judges all six `AggregationFunction` members against the table, through the same |
| 62 | + `DATASET_INVALID` / 400 door. The refusal message names the divergence its own |
| 63 | + aggregate class really has (`min` / `max` SELECT a stored value and diverge on order; |
| 64 | + `sum` / `avg` DERIVE a number and diverge on arithmetic) and prescribes accordingly. |
| 65 | +- **`measureResultType`** asks `isAggregateCompatibleWithFieldType` before it answers, so |
| 66 | + the rule and the table agree **by construction**. Its `STRING_SOURCE_FIELD_TYPES` |
| 67 | + branch and its `formula` branch are retired with them; `min` / `max` over the temporal |
| 68 | + class still answers `'time'`, unchanged. |
| 69 | +- **`AnalyticsServiceConfig.sourceFieldMeta`** no longer declares `returnType`. It was |
| 70 | + carried (#16236) for one reader — the retired `formula` branch — and a declared input |
| 71 | + nobody consumes is the declared-not-enforced shape Prime Directive #10 refuses. |
| 72 | + |
| 73 | + ⚠️ **That key was never released, so against every published version this removal is a |
| 74 | + no-op.** #16236 is still a pending changeset in the same release window as this one; |
| 75 | + the last published entry (17.4.0) says in as many words that `FieldSchema.returnType` |
| 76 | + "is not on `AnalyticsServiceConfig.sourceFieldMeta`'s return shape". The key was |
| 77 | + therefore added and removed inside one window and no published tarball ever carried it. |
| 78 | + |
| 79 | + **Host fix, one line:** drop `returnType` from whatever your `sourceFieldMeta` returns. |
| 80 | + You do not have to — the hook is a function RETURN position, so an extra key is not an |
| 81 | + excess-property error and is simply ignored at runtime — but keeping it declares an |
| 82 | + input nothing reads. Hosts on `AnalyticsServicePlugin` need no change at all: the plugin |
| 83 | + stopped relaying the key in this same change. |
| 84 | + |
| 85 | +## FROM → TO, and the one-line fix |
| 86 | + |
| 87 | +| you wrote | write instead | |
| 88 | +|---|---| |
| 89 | +| `{ aggregate: 'min' \| 'max', field: <a text/select/lookup/autonumber field> }` | `count` / `count_distinct` if you were counting; a **sort** on the list/report if you wanted the first or last RECORD | |
| 90 | +| `{ aggregate: 'min' \| 'max', field: <a json/multiselect/file/location/vector field> }` | store the quantity you meant as a numeric or temporal field and aggregate that | |
| 91 | +| `{ aggregate: 'min' \| 'max', field: <a formula field> }` | a formula emits no column; aggregate the stored field the formula reads, or persist the computed value | |
| 92 | + |
| 93 | +⚠️ **Untouched:** those field types used as a **DIMENSION** (grouping, labelling, |
| 94 | +bucketing, filtering), `count` / `count_distinct` over any type, `min` / `max` over the |
| 95 | +numeric, temporal and boolean classes, and every `sum` / `avg` row #16778 and #16099 |
| 96 | +already settled. The refusal also still stands down rather than guessing wherever the |
| 97 | +declared type cannot be resolved: no `sourceFieldMeta` wired, an unknown field, or a |
| 98 | +`relationship.field` path whose column lives on a joined object. |
| 99 | + |
| 100 | +⚠️ The hand-migration prescription ships as the ADR-0087 semantic TODO registered above, |
| 101 | +which names the measure and the field type per affected pair — no lossless conversion |
| 102 | +exists, because nothing can compute "the smallest text value" in a way every backend |
| 103 | +agrees on. |
0 commit comments