Skip to content

Commit 99589f9

Browse files
feat(spec,service-analytics)!: retire the cube metric types number / string / boolean, refused in both analytics strategies in the spec's words (#21000) (#21452)
Fixes #21000 Clause-②: no (narrowing) Dispatched by the claim `5955932074` (PM loop round 1, `domain:spec` seat 1, session `session_01UtnxvdiN376GF3sgXwAw4d`), on triage's answer B `5952826307`: the enum retirement only. The row wildcard boundary is #21409's (landed as `b79301000c`, merged here). ## What this does **`@objectstack/spec`: `AggregationMetricType` loses `number`, `string` and `boolean`** (ADR-0049 enforce-or-remove, grade `5923362062`). - They declared "a custom SQL expression returning a number / string / boolean": the measure's `sql` was the whole computation. Since ruling D on #20943 (`5d5e679873`), a cube member's `sql` is a column reference, so the three had nothing left to compute. - The enum is declared through `enumWithRetiredValues` (`shared/retired-key.ts`), the house value-level mechanism. The six aggregates (`count`, `sum`, `avg`, `min`, `max`, `count_distinct`) are the whole vocabulary. - An authored retired type fails `tsc`, because it is gone from the type. It is refused at parse with a named prescription, at the enum, at a metric's `type` and at a cube's `measures.METRIC.type`. - The prescription names the aggregate that fits: `sum`, `avg`, `min` or `max` over the column; `count` over `'*'` or over a column; or `count_distinct`. A per-row value becomes a stored or formula field of the object that the measure aggregates. A value derived from measures is `derived: { op, of }` on an ADR-0021 dataset. - A value the enum never declared keeps zod's own message. - The prescriptions are module-private, so the export surface does not grow. - **ADR-0087:** D3 entry `cube-metric-expression-types-retired` (`migrations/entries/semantic/`), with a step-18 rationale fragment (order 62). `registry.ts` was regenerated by `gen:migration-registry` after each merge, never edited by hand. - There is no D2 conversion, by design: the column alone does not say which aggregate the author meant, and a stored cube is refused, never rewritten. - There is no `RETIRED_KEYS_BY_MAJOR` row, because no key left the shape. - **Liveness:** the `analytics_cube` row `measures.type` stays `live`, re-verified 2026-10-02. The narrowing is recorded, and the evidence now names `aggregateOfMeasure` instead of the partition. - **Generated:** only `content/docs/references/data/analytics.mdx` moved, losing the three values from the enum list and from both `type` cells. - `api-surface`, `authorable-surface`, `json-schema.manifest` and `api-surface-signatures` are byte-identical, as the playbook predicts for an enum-value narrowing. - `spec-changes.json` and the upgrade guide stay at protocol 17, and both checks are green. - The module header gained an `@module data/analytics` marker. Without it, moving the imports below the header dropped the page's opening paragraph: `lib/file-description.ts` rule 3 does not select a block inside the import list without the marker. The page's description is byte-identical to `main`. - The neighbouring `CUBE_MEMBER_SQL` docblock no longer says that the ObjectQL path refuses the partition, in the present tense. **`@objectstack/service-analytics`: the `EXPRESSION_METRIC_TYPES` partition is deleted.** It is replaced by ONE verdict both strategies call, `aggregateOfMeasure` (`strategies/native-sql-strategy.ts`). - `aggregateOfMeasure` admits a type this runtime lowers (the `AGGREGATE_SQL` keys, pinned EQUAL to the enum's options). It refuses everything else with the SPEC's own words, `AggregationMetricType.safeParse(type)`, so the runtime keeps no list of metric types, retired or otherwise. - `NativeSQLStrategy#resolveMeasureSql` asks it before anything is lowered. The verbatim emit is gone, and so is the unrecognised-type throw it replaced. - `ObjectQLStrategy#resolveMeasureAggregation` asks it at the one resolver both doors call. The `INVALID_FIELD` arm for the partition is gone. - Comments that named the partition or the three types are updated: `plugin.ts` (the bridge's comment, its docblock, and its runtime message, which said "a custom-SQL measure is refused earlier"), `preview-evaluator.ts`, `analytics-service.ts`, `cube-measure-field-type-door.ts` and `dataset-refusal.ts`. **`@objectstack/lint` (test and comment only).** #21435 landed between my merges with a pin asserting that the three types sit outside the aggregate table. That is false after this retirement, so the assertion now reads `[]`. The skip-5 `silent` case is kept. No changeset is needed: a comment and a test, nothing in the published output changes. ## Clause-②, measured `node scripts/pm/check-widening-tells.mjs --declaration no --diff` (the merge-base diff against `b79301000c`) exits 0, with no widening tell. Three key lines are reported as a stated silence. They are the retired-member prescription entries `number:` / `string:` / `boolean:` in the `enumWithRetiredValues` map, which are refusals, not accept-set members. No export-listing row was added (`check:api-surface` green, byte-identical), so the line is `Clause-②: no (narrowing)`. Both changesets are BREAKING, with `!`, a **BREAKING** banner, the `(narrowing)` arm, exactly one ADR-0087 marker (`registered cube-metric-expression-types-retired`) and `minor`. ## Census (examples, packages, platform objects, objectui) The instrument is an AST walk over every object-literal member of a `measures:` record. It covered 7,654 `.ts`/`.js` files under `examples/**` and `packages/**` at `4ec505761d`, platform objects included. - 321 measure entries in total. Lit control: `count` 164, `sum` 71. - Retired-type entries: 7. All are deliberate refusal fixtures in the `service-analytics` tests, built without the parse. - Zero hits in `examples/**`, in non-test `packages/**`, in `skills/**` and in `content/docs/**` (one cube example there, `count` / `sum`). - `examples/app-showcase/src/data/analytics/showcase.cube.ts`: 3 measures (`count`, `sum`, `avg`). Its three `type: 'string'` lines (48, 53, 63) are **dimensions**. `DimensionType` is a separate enum, unchanged, and pinned in the new test file. - JSON fixtures carrying record-form `measures`: zero. - objectui at the `.objectui-sha` pin `89cad75d55`: 0 mentions of `AggregationMetricType`, and 0 record-form measure entries over 528 files that mention `measures`. Control: `clientValidation.ts` names `CubeSchema`. The Console Pin Gate is not at risk: no export left. ## Premise check (zone 2) 1. **Holds.** On `68c5ab7eba`, `AggregationMetricType` (`data/analytics.zod.ts:27`) listed the three, and `MetricSchema.type` used it. Measured through `AnalyticsService` with a column `sql`: - the raw-SQL path SERVED the column unaggregated: `SELECT status AS "status", amount AS "m" FROM "orders" GROUP BY status`; - the ObjectQL path refused the measure `INVALID_FIELD` / 400. 2. `api/analytics.zod.ts:231`: the `/analytics/meta` member's describe ("Aggregation type for a measure (`AggregationMetricType`)") is not made false by the retirement, so it is not edited. 3. That `type` is a separate `z.string()` field, deliberately not the enum, because the projection copies the value verbatim. Measured: it is not the enum. ## What a stored cube carrying a retired type meets (fail closed, never stood down) **Pinned in `cube-metric-expression-types-retirement.test.ts`:** - the artifact boot door (`ObjectStackDefinitionSchema`, the parse `MetadataPlugin` runs a built artifact through) refuses it at `analyticsCubes.0.measures.m.type` with the prescription; - `defineStack` refuses it with `STACK_SCHEMA_INVALID` / 422; - `defineCube` and the `analytics_cube` write door (`getMetadataTypeSchema('analytics_cube')`, what `PUT /api/v1/meta/analytics_cube/NAME` validates) refuse it too; - the rehydration seam (`applyConversionsToStoredItem`) replays NOTHING over it. Control: the same row's retired sub-day granularity IS rewritten, so the seam is live. The stored row reaches the parse as stored, and the parse refuses it. **Measured through the real dispatcher routes** (a temporary `packages/runtime` probe, not committed), for a cube a host registers in-process WITHOUT the parse: - `POST /api/v1/analytics/query` and `POST /api/v1/analytics/sql`, on both strategies: `500`, with `error.message` carrying the spec's prescription verbatim. Nothing executed. - `sum` control: `200`. - `GET /api/v1/analytics/meta`: `200`, listing the measure with `type: "number"` as registered (see the Acceptance notes). ## Merges (serial constraints) - `0d182f0549` merged `main` at `3a6d92f78b`, bringing PR #21424 (#21376, the NativeSQL filter-compile region) and PR #21425 (#21293, its registry entry). Clean. - `587d9d4b63` merged `main` at `d7d5b4f96a`. One hand conflict, in the `objectql-strategy.ts` import from `native-sql-strategy.js`: #21440 added `windowClauseSql`, and both are kept. - `4ec505761d` merged `main` at `b79301000c`, bringing PR #21431 (#21409, the count-only boundary). One hand conflict, in the `analytics.zod.ts` imports: `enumWithRetiredValues` and #21409's `ANALYTICS_COLUMN_PATH` / `rowWildcardOutsideCount` / `rowWildcardOutsideCountRefusal`, both kept. - Every merge went through `scripts/pm/os-regen-merge.sh`. Its step 4 was run each time, and `gen:migration-registry` afterwards wrote no diff. - **Registry reading at `4ec505761d`:** 349 semantic, 244 retired-key and 212 retired-def entries. The siblings' ids `analytics-row-wildcard-outside-count-refused` and `dashboard-widget-single-series-multi-measure-refused` appear at the same counts as on `main` (1 each). `cube-metric-expression-types-retired` appears twice: the semantic entry and its step-18 rationale fragment. - **Ledger reading at `4ec505761d`:** the `measures.sql` and `dimensions.sql` notes carry #21409's count-only wording and no longer name this card. `measures.type` carries this retirement's own wording. ## Tests (head `4ec505761d`) - `@objectstack/spec`: - `vitest --project local`: 602 files, 17737 passed, 1 todo; - `--project repo`: 43 of 49 files, 705 passed (the other six are NOT MEASURED, below); - `typecheck` (`tsc`, scripts, test layer): OK. The new `@ts-expect-error` (a typed `Metric` with `type: 'number'`) sits in the compiled test program. - `@objectstack/service-analytics`: 170 files, 3846 passed, 126 skipped. `typecheck` OK. - The reverse verification happened on the way: a fixture typed `Cube` with `type: 'number'` failed `tsc` with TS2322 against the rebuilt `.d.ts` until it was cast. - `@objectstack/lint`: 119 files, 5592 passed. `typecheck` OK. ## Ablations Both run from the committed tree through `scripts/ablation-replace.mjs`. In each, the anchor hit once and the blob changed; the restore was proved by blob equal to `HEAD` and an empty `git diff HEAD`. Both subjects resolve from `src`, so no rebuild was needed. - **The runtime verdict admits every string** (`aggregateOfMeasure`'s table check removed): 28 failed, 12 passed, over `metric-type-coverage`, `measure-expression-both-strategies` and `measure-expression-sql`. - Every refusal case went red: both strategies, both doors, and the drift case. - The admitted-aggregate, cross-object-twin and coverage-equality cases stayed green, which is the predicted direction. - **The spec's `number` prescription is unmapped:** 8 failed, 17 passed in `cube-metric-expression-types-retirement.test.ts`. Every `number` door pin went red; the `string` / `boolean` pins and the controls stayed green. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `4ec505761d` derived 117 families. All 117 were run, each exit code recorded, and `--ran` answered "117 derived, 117 run, 0 NOT-MEASURED (a DERIVED zero)". Among them: - `check-adr-0087-registration` ("2 declared-breaking changeset(s), each carrying an ADR-0087 disposition"); - `check-changeset-no-major`; - `check:generated` ("All 15 generated artifacts are up to date"); - `check:liveness`, `check:doc-authoring`, `check:nul-bytes` and `check:skill-examples` (after building the client closure); - `check:type-check-debt` (325 s); - `check:dual-build-cjs-loads` (after a whole-repo build, 71 of 72 tasks cached). **NOT MEASURED** - `@objectstack/spec` repo-project files `build-schemas-check-mode`, `dist-freshness`, `dist-freshness-adoption`, `publish-smoke-boot-failure`, `publish-smoke-port-collision` and `schema-tree-freshness`. Reason: they drive whole builds and exercise build tooling this diff does not touch. - `@objectstack/cli` integration tier, declared to CI. ## Acceptance notes - **`GET /analytics/meta` still lists a host-registered unparsed cube's measure with its retired `type`**, while the query doors refuse it. This is pre-existing for any enum-invalid type (`median` read the same at base). It is reachable only by a host that registers a cube literal without `CubeSchema`, because every parsing door refuses it first. Not filed; carrier: none. - **A stored `analytics_cube` row** read at `GET /api/v1/meta/analytics_cube/NAME` comes back as stored. Stored rows keep being read (the runtime gate's D4 asymmetry), and no conversion rewrites it (pinned at the seam). - Re-saving it is refused with the prescription (the write door pin). - The analytics registry has no metadata read path (#20965's measurement), so such a row reaches no query. - The HTTP read itself was NOT MEASURED through the route. - **The ObjectQL envelope for the three moves from `INVALID_FIELD` / 400 to the undeclared-500 tier.** The message is readable and carries the prescription. This is the tier `dataset-refusal.ts` assigns to a cube that never met the parse, and the changeset says so. - A never-declared type (`median`) on the ObjectQL path is now refused at the resolver in the same tier. Before, it was forwarded to `executeAggregate`: the auto-bridge refused it, a host's own executor received it, and `/analytics/sql` echoed `MEDIAN(amount)`. - `aggregate-bridge-function-vocabulary.test.ts` therefore pins both seams. The bridge is driven directly through the service's strategy context, because no cube path reaches it with a non-aggregate method any more. - **The `measures.sql` ledger note** (#20943's, re-pointed by #21409) was made false by this diff, as at-tier record `5959409102` ruled. It was corrected in patch round 2 (`4278601b82`): both runtime expression branches are gone (the gate's stand-down with #20965, the raw-SQL verbatim emit here). What remains for a cube that reaches the service without meeting the parse is `qualifyAndRegisterJoin` passing a non-column `sql` through inside the aggregate, measured through `generateSql`. - **The prescriptions say "removed … in @objectstack/spec 17.7.0"**, assuming the next release is a minor (the label is 17.6.0, which is published). If the next release is cut as a major, those runtime strings need the new number. - **Files beyond the claim's list**, all comments or tests that named the partition or the types, or that the merges made false: - `service-analytics`: `analytics-service.ts`, `cube-measure-field-type-door.ts`, `dataset-refusal.ts`, and the tests `aggregate-bridge-function-vocabulary`, `caller-member-column-reference-gate`, `cube-authored-format-granularity`, `field-read-admission-gate` and `unlisted-refusal-envelope`; - `lint`: `validate-dataset-measure-aggregates` (source comment and test). --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1fd5664 commit 99589f9

27 files changed

Lines changed: 1005 additions & 420 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
---
4+
5+
fix(service-analytics)!: both analytics strategies refuse a cube measure whose `type` names no aggregate, in the spec's words — the custom-SQL `EXPRESSION_METRIC_TYPES` partition is gone with the three types it named (#21000)
6+
7+
**BREAKING** — `@objectstack/spec` retired the cube metric types `number`, `string`
8+
and `boolean` from `AggregationMetricType` (a measure's `sql` is a column reference,
9+
so they had nothing left to compute). Every door that parses a cube refuses them;
10+
this release removes the runtime branches that still served them for a cube that
11+
reached the analytics service WITHOUT meeting that parse — one a host registers
12+
in-process from a literal, through `AnalyticsServicePlugin({ cubes })` or
13+
`AnalyticsService({ cubes })` (the registry never parses).
14+
15+
| | before | now |
16+
| --- | --- | --- |
17+
| `NativeSQLStrategy`, a measure typed `number` / `string` / `boolean` | served: the column emitted UNAGGREGATED in the statement (`amount AS "m"` beside `GROUP BY`) | refused, nothing executed |
18+
| `ObjectQLStrategy`, the same measure | refused `INVALID_FIELD` / 400 | refused, nothing executed |
19+
| either strategy, a type the spec never declared (`median`) | native: refused; ObjectQL: forwarded to `executeAggregate` as the method (the auto-bridge refused it; a host's own executor received it), and `/analytics/sql` echoed `MEDIAN(amount)` | refused, nothing executed |
20+
21+
**The one refusal** is `aggregateOfMeasure`'s, shared by both strategies and both
22+
doors (`POST /analytics/query` and `POST /analytics/sql`): it names the measure and
23+
the cube, then quotes the spec's own verdict on the type — for a retired type the
24+
retirement prescription (the six aggregates to choose from, and where a per-row or
25+
derived value goes instead), for anything else zod's message listing the six. It is
26+
a bare `Error`, the undeclared-500 tier this package assigns to a cube that never
27+
met the parse, so the HTTP answer is `500` with the message readable in the body
28+
(measured through the dispatcher's analytics route), never a caller-blaming `400`.
29+
The ObjectQL envelope for the three retired types therefore moves from
30+
`INVALID_FIELD` / 400 to that tier.
31+
32+
**The fix:** give the measure one of the six aggregate types — `count`, `sum`,
33+
`avg`, `min`, `max`, `count_distinct` — or parse the cube through `CubeSchema`
34+
before registering it, which refuses the same types with the same prescription.
35+
36+
**Removed export:** `EXPRESSION_METRIC_TYPES` from
37+
`strategies/native-sql-strategy.ts` (internal to the package; not re-exported from
38+
its entry point). **Unchanged:** every aggregate measure on both strategies, the
39+
auto-bridge's own parse of an engine method (still pinned, driven directly), and
40+
`GET /analytics/meta`, which keeps publishing each registered measure's `type` as
41+
registered.
42+
43+
Clause-②: no (narrowing)
44+
45+
<!-- adr-0087: registered cube-metric-expression-types-retired -->
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: retire the cube metric types `number`, `string` and `boolean` — a measure's `sql` is a column reference, so the custom-SQL-expression types had nothing left to compute (#21000)
6+
7+
**BREAKING** — three members leave `AggregationMetricType`, so a cube measure's
8+
`measures.<metric>.type` no longer accepts `number`, `string` or `boolean`. ADR-0049
9+
enforce-or-remove. They declared "a custom SQL expression returning a number /
10+
string / boolean": the measure's `sql` was the whole computation. A cube member's
11+
`sql` is a column reference since `cube-member-sql-expression-retired` (#20943), so
12+
the three were left naming nothing: measured before this change, the raw-SQL
13+
analytics path returned the referenced column UNAGGREGATED (a bare column in a
14+
grouped statement — by SQL's own rules an error on PostgreSQL and an arbitrary row's
15+
value on SQLite), and the ObjectQL path refused the measure. The six aggregates — `count`, `sum`,
16+
`avg`, `min`, `max`, `count_distinct` — are unchanged and are now the whole
17+
vocabulary.
18+
19+
### FROM → TO
20+
21+
| removed | what to write instead |
22+
| --- | --- |
23+
| `measures.<metric>.type: 'number'`, `'string'` or `'boolean'` | the aggregate the measure means: `sum`, `avg`, `min` or `max` over the column; `count` (over `'*'` for a row count, or over a column for its non-null values); or `count_distinct`. |
24+
| a measure whose old expression computed a value per row | keep that value as a field of the object (a stored or formula field) and aggregate the field. |
25+
| a measure whose old expression combined measures (a ratio, a difference) | `derived: { op, of: [...] }` on an ADR-0021 dataset over the same object. |
26+
27+
**The one-line fix: give the measure an aggregate type.** There is no mechanical
28+
rewrite — the column alone does not say whether `amount` meant its sum, its average
29+
or its largest value — so `os migrate meta` lists nothing for this change.
30+
31+
Each retired member is refused at parse with a prescription naming the six
32+
aggregates, at the measure's `type`, and in `tsc` (the members are gone from the
33+
`AggregationMetricType` type). A value the enum never declared keeps zod's own
34+
message.
35+
36+
### The retirement kit
37+
38+
- **Value-level retirement.** `AggregationMetricType` is declared through
39+
`enumWithRetiredValues` (`shared/retired-key.ts`), with the prescriptions
40+
module-private. No authorable KEY and no def changed, so nothing lands in
41+
`RETIRED_KEYS_BY_MAJOR`, and the four surface ratchets (`api-surface`,
42+
`authorable-surface`, `json-schema.manifest`, `api-surface-signatures`) are
43+
byte-identical.
44+
- **No D2 conversion, by design.** A stored or built cube that still carries one of
45+
the three is REFUSED, never rewritten or dropped: the boot door
46+
(`ObjectStackDefinitionSchema`, which a built artifact is parsed through), the
47+
`analytics_cube` write door and `defineStack` refuse it with the prescription, and
48+
the rehydration seam replays no conversion over it.
49+
- **D3 entry `cube-metric-expression-types-retired`**, with its step-18 rationale
50+
fragment, carries the judgement the upgrader owes: which aggregate each measure
51+
meant.
52+
- **Liveness.** The `analytics_cube` row `measures.type` stays `live`, re-verified
53+
2026-10-02, with the narrowing recorded.
54+
- **Docs.** The `data/analytics` reference page is regenerated.
55+
- **No deprecation window**, per the project's startup-stage posture.
56+
57+
### Reach, measured
58+
59+
- This repository authors no cube measure of the three types outside tests:
60+
`examples/**`, `packages/**` (the platform objects included) and the skills and
61+
docs carry none. The showcase cube's `type: 'string'` entries are dimensions,
62+
whose `DimensionType` is a separate enum and is unchanged.
63+
- objectui at its pinned commit carries no `AggregationMetricType` mirror and no
64+
cube measure of the three types.
65+
- Out-of-repo authored cubes: NOT MEASURED.
66+
67+
Clause-②: no (narrowing)
68+
69+
<!-- adr-0087: registered cube-metric-expression-types-retired -->

‎content/docs/references/data/analytics.mdx‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,6 @@ const result = AggregationMetricType.parse(data);
4040
* `min`
4141
* `max`
4242
* `count_distinct`
43-
* `number`
44-
* `string`
45-
* `boolean`
4643

4744

4845
---
@@ -126,7 +123,7 @@ Type: `[string, string]`
126123
| **title** | `string` | optional | |
127124
| **description** | `string` | optional | |
128125
| **sql** | `string` | ✅ | Base SQL statement or Table Name |
129-
| **measures** | `Record<string, { label: string; description?: string; type: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>; sql: string; … }>` | ✅ | Quantitative metrics, keyed by metric name: the record key IS the metric's name, published and queried as `<cube>.<key>`. A metric declares no inner `name`. |
126+
| **measures** | `Record<string, { label: string; description?: string; type: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; sql: string; … }>` | ✅ | Quantitative metrics, keyed by metric name: the record key IS the metric's name, published and queried as `<cube>.<key>`. A metric declares no inner `name`. |
130127
| **dimensions** | `Record<string, { label: string; description?: string; type: Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>; sql: string; … }>` | ✅ | Qualitative attributes, keyed by dimension name: the record key IS the dimension's name, published and queried as `<cube>.<key>`. A dimension declares no inner `name`. |
131128
| **joins** | `Record<string, { name: string }>` | optional | |
132129
| **refreshKey** | `never` | optional | [REMOVED] `analytics_cube.refreshKey` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing read it: no analytics result is cached, so neither `every` nor `sql` ever refreshed anything. Delete the key; every analytics query is computed when it is asked. A refresh cadence is declared again when a result cache exists. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
@@ -146,7 +143,7 @@ Type: `[string, string]`
146143
| **name** | `never` | optional | [REMOVED] `measures.<metric>.name` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it never had an effect: the record key is the metric's name. Every consumer resolves a metric by its key in `measures` (`GET /analytics/meta` publishes it as `<cube>.<key>`, and a query names it that way), so the inner `name` was a second copy of the identity that nothing read, and one that disagreed with its key was silently ignored. Delete the key. To rename a metric, rename its key in `measures` — and every query, dashboard and report that names `<cube>.<key>`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
147144
| **label** | `string` | ✅ | Human readable label |
148145
| **description** | `string` | optional | |
149-
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
146+
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | |
150147
| **sql** | `string` | ✅ | Column reference: a field of the cube's object ("amount"), a relationship path ending in one ("account.amount"), or "*" for a count. Never a SQL expression: a derived value is declared on an ADR-0021 dataset (a measure-scoped filter, or derived: `{ op, of }`). |
151148
| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta. |
152149

@@ -219,7 +216,7 @@ Type: `[string, string]`
219216
| **name** | `never` | optional | [REMOVED] `measures.<metric>.name` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it never had an effect: the record key is the metric's name. Every consumer resolves a metric by its key in `measures` (`GET /analytics/meta` publishes it as `<cube>.<key>`, and a query names it that way), so the inner `name` was a second copy of the identity that nothing read, and one that disagreed with its key was silently ignored. Delete the key. To rename a metric, rename its key in `measures` — and every query, dashboard and report that names `<cube>.<key>`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
220217
| **label** | `string` | ✅ | Human readable label |
221218
| **description** | `string` | optional | |
222-
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
219+
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | |
223220
| **sql** | `string` | ✅ | Column reference: a field of the cube's object ("amount"), a relationship path ending in one ("account.amount"), or "*" for a count. Never a SQL expression: a derived value is declared on an ADR-0021 dataset (a measure-scoped filter, or derived: `{ op, of }`). |
224221
| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta. |
225222

‎packages/lint/src/validate-dataset-measure-aggregates.test.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -886,8 +886,8 @@ describe('the cube leg — an analyticsCubes member the analytics door refuses i
886886
* `shape`, as the two door modules state their rules — read off the spec's
887887
* table and predicates, never a retyped list:
888888
*
889-
* - a `type` that is no row of the table (the expression metric types) is
890-
* judged by neither door;
889+
* - a `type` that is no row of the table (the retired custom-SQL metric
890+
* types, which the schema refuses) is judged by neither door;
891891
* - `count_distinct` — `structured-json-dimension-door.ts`: refused when the
892892
* row refuses the type OR the declaration is multi-value;
893893
* - every other row — `cube-measure-field-type-door.ts`: refused when the row
@@ -980,8 +980,11 @@ describe('the cube leg — every cube measure is judged by the aggregate × fiel
980980
expect(accepted, `${type} accepts`).toBeGreaterThan(5);
981981
}
982982
}
983-
// The expression metric types are in the population and outside the table.
984-
expect(types.filter((t) => !rows.includes(t)).sort()).toEqual(['boolean', 'number', 'string']);
983+
// Every cube measure `type` IS a row: the custom-SQL metric types that
984+
// used to sit outside the table (`number` / `string` / `boolean`) were
985+
// retired from `AggregationMetricType` (#21000), so the population and the
986+
// table are one vocabulary.
987+
expect(types.filter((t) => !rows.includes(t)).sort()).toEqual([]);
985988
});
986989

987990
it('reads a relationship path on the object the last hop reaches — the lookup\'s reference, or the join the cube declares for it', () => {
@@ -1002,7 +1005,8 @@ describe('the cube leg — every cube measure is judged by the aggregate × fiel
10021005
it('never hands the predicate a guess: no type, a type outside the table, the row wildcard, an unresolved column', () => {
10031006
const silent = (measures: Record<string, unknown>, ledger: Record<string, unknown> = {}) =>
10041007
expect(validateDatasetMeasureAggregates(cubeStack({ measures }, {}, ledger))).toEqual([]);
1005-
// The expression metric types are outside the table's vocabulary (skip 5).
1008+
// The retired custom-SQL metric types are outside the table's vocabulary
1009+
// (skip 5): the schema refuses them, so this leg leaves them to it.
10061010
silent({ n: measure('number', 'name'), s: measure('string', 'name'), b: measure('boolean', 'name') });
10071011
// A prototype key is not a row.
10081012
silent({ p: measure('toString', 'name') });

‎packages/lint/src/validate-dataset-measure-aggregates.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,9 @@
159159
* `cube-measure-field-type-door.ts` half, which reads the TYPE alone — the
160160
* `multiple` flag moves only `count_distinct`, see above), and never
161161
* `count`, which reads no value and accepts every type. A `type` outside
162-
* the table's vocabulary — the expression metric types `number` /
163-
* `string` / `boolean` — is skip 5, as on a dataset, and neither door
164-
* judges it.
162+
* the table's vocabulary — since the custom-SQL metric types `number` /
163+
* `string` / `boolean` were retired (#21000), one the schema itself
164+
* refuses — is skip 5, as on a dataset, and neither door judges it.
165165
*
166166
* How a cube names its column is read the way the door reads it
167167
* (`analytics-service.ts`, `hop-object.ts`):

‎packages/services/service-analytics/src/__tests__/aggregate-bridge-function-vocabulary.test.ts‎

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,25 @@
1919
*
2020
* ## Why this refusal is deliberately NOT in the ADR-0112 envelope
2121
*
22-
* The reachable producer of a non-aggregate method — a custom-SQL measure
23-
* (`AggregationMetricType` `number`/`string`/`boolean`) — is refused earlier
24-
* and caller-facing by `ObjectQLStrategy.resolveMeasureAggregation` (commit 017130a09,
25-
* `INVALID_FIELD` / 400). Anything still arriving at the bridge is host drift
26-
* (an unparsed cube object, our own drift), which `dataset-refusal.ts`'s module
27-
* header assigns to the bare-`Error`, undeclared-500 tier — the same tier it
28-
* assigns to `native-sql-strategy.ts`'s "measure … has unrecognised type". The
29-
* absence of a `code` is therefore asserted, not overlooked: enveloping this as
30-
* a 400 would tell the author to fix something they did not write.
22+
* A non-aggregate method is host drift (an unparsed cube object, our own
23+
* drift), which `dataset-refusal.ts`'s module header assigns to the
24+
* bare-`Error`, undeclared-500 tier. The absence of a `code` is therefore
25+
* asserted, not overlooked: enveloping this as a 400 would tell the author to
26+
* fix something they did not write.
27+
*
28+
* ## [#21000] Two seams, one tier
29+
*
30+
* The custom-SQL metric types (`AggregationMetricType` `number` / `string` /
31+
* `boolean`) used to be refused `INVALID_FIELD` / 400 by
32+
* `ObjectQLStrategy.resolveMeasureAggregation` (commit 017130a09), and every
33+
* OTHER non-aggregate type — `median`, a cube that never met the parse — was
34+
* forwarded on to this bridge. The three were retired from the spec, and the
35+
* resolver now answers every type no aggregate lowers itself
36+
* (`aggregateOfMeasure`), in the same undeclared-500 tier, in the spec's words.
37+
* So the cube path no longer reaches the bridge with one, and this file pins
38+
* both seams: the cube path refused at the resolver, nothing reaching the
39+
* engine; and the bridge itself, driven directly, still parsing whatever
40+
* method arrives before the engine sees it.
3141
*/
3242

3343
import { describe, it, expect, vi } from 'vitest';
@@ -114,15 +124,41 @@ describe('[#11833] the aggregate auto-bridge speaks the engine contract vocabula
114124
expect(AggregationFunction.options).toContain(calls[0].aggregations?.[0].function);
115125
});
116126

117-
it('refuses a method outside the engine vocabulary instead of forwarding it', async () => {
127+
it('a cube measure whose type names no aggregate is refused at the resolver, before the bridge', async () => {
118128
// Host drift: a cube object registered without meeting `CubeSchema`, so its
119-
// `type` never faced the enum's parse. This is the arrival path the tiering
120-
// note above describes.
129+
// `type` never faced the enum's parse. Since #21000 the strategy's resolver
130+
// refuses it — the same tier the bridge answers in, in the spec's words.
121131
const calls: EngineAggregateCall[] = [];
122132
const service = await analyticsVia(fakeEngine(calls, schema), cubeWithMeasureType('median'));
123133

124134
const err = await service.query(selection as never).then(() => null, (e: Error) => e);
125135

136+
expect(err).toBeInstanceOf(Error);
137+
expect(err?.message).toContain('measure "revenue" on cube "sales" cannot be served: its type "median"');
138+
for (const fn of AggregationFunction.options) expect(err?.message).toContain(fn);
139+
// Undeclared-500 tier, deliberately: no ADR-0112 envelope on this family.
140+
expect((err as Error & { code?: string }).code).toBeUndefined();
141+
// The load-bearing half — the bad type never reached the engine.
142+
expect(calls).toHaveLength(0);
143+
});
144+
145+
it('the bridge itself still refuses a method outside the engine vocabulary instead of forwarding it', async () => {
146+
// Driven DIRECTLY: no cube path reaches the bridge with a non-aggregate
147+
// method any more (the case above), so the seam is exercised as the
148+
// strategy calls it — the service's strategy context's `executeAggregate`,
149+
// which is the plugin's auto-bridge — with a method the engine does not
150+
// declare.
151+
const calls: EngineAggregateCall[] = [];
152+
const service = await analyticsVia(fakeEngine(calls, schema), cubeWithMeasureType('sum'));
153+
const bridge = (service as unknown as {
154+
baseCtx: { executeAggregate: (object: string, options: unknown) => Promise<unknown> };
155+
}).baseCtx.executeAggregate;
156+
157+
const err = await bridge('opportunity', {
158+
groupBy: ['region'],
159+
aggregations: [{ field: 'amount', method: 'median', alias: 'revenue' }],
160+
}).then(() => null, (e: Error) => e);
161+
126162
expect(err).toBeInstanceOf(Error);
127163
// The wording IS the contract here: it must name the offending method, the
128164
// aggregation it belongs to, and the legal vocabulary.

0 commit comments

Comments
 (0)