Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/21000-analytics-metric-type-verdict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
'@objectstack/service-analytics': minor
---

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)

**BREAKING** — `@objectstack/spec` retired the cube metric types `number`, `string`
and `boolean` from `AggregationMetricType` (a measure's `sql` is a column reference,
so they had nothing left to compute). Every door that parses a cube refuses them;
this release removes the runtime branches that still served them for a cube that
reached the analytics service WITHOUT meeting that parse — one a host registers
in-process from a literal, through `AnalyticsServicePlugin({ cubes })` or
`AnalyticsService({ cubes })` (the registry never parses).

| | before | now |
| --- | --- | --- |
| `NativeSQLStrategy`, a measure typed `number` / `string` / `boolean` | served: the column emitted UNAGGREGATED in the statement (`amount AS "m"` beside `GROUP BY`) | refused, nothing executed |
| `ObjectQLStrategy`, the same measure | refused `INVALID_FIELD` / 400 | refused, nothing executed |
| 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 |

**The one refusal** is `aggregateOfMeasure`'s, shared by both strategies and both
doors (`POST /analytics/query` and `POST /analytics/sql`): it names the measure and
the cube, then quotes the spec's own verdict on the type — for a retired type the
retirement prescription (the six aggregates to choose from, and where a per-row or
derived value goes instead), for anything else zod's message listing the six. It is
a bare `Error`, the undeclared-500 tier this package assigns to a cube that never
met the parse, so the HTTP answer is `500` with the message readable in the body
(measured through the dispatcher's analytics route), never a caller-blaming `400`.
The ObjectQL envelope for the three retired types therefore moves from
`INVALID_FIELD` / 400 to that tier.

**The fix:** give the measure one of the six aggregate types — `count`, `sum`,
`avg`, `min`, `max`, `count_distinct` — or parse the cube through `CubeSchema`
before registering it, which refuses the same types with the same prescription.

**Removed export:** `EXPRESSION_METRIC_TYPES` from
`strategies/native-sql-strategy.ts` (internal to the package; not re-exported from
its entry point). **Unchanged:** every aggregate measure on both strategies, the
auto-bridge's own parse of an engine method (still pinned, driven directly), and
`GET /analytics/meta`, which keeps publishing each registered measure's `type` as
registered.

Clause-②: no (narrowing)

<!-- adr-0087: registered cube-metric-expression-types-retired -->
69 changes: 69 additions & 0 deletions .changeset/21000-cube-metric-expression-types-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
'@objectstack/spec': minor
---

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)

**BREAKING** — three members leave `AggregationMetricType`, so a cube measure's
`measures.<metric>.type` no longer accepts `number`, `string` or `boolean`. ADR-0049
enforce-or-remove. They declared "a custom SQL expression returning a number /
string / boolean": the measure's `sql` was the whole computation. A cube member's
`sql` is a column reference since `cube-member-sql-expression-retired` (#20943), so
the three were left naming nothing: measured before this change, the raw-SQL
analytics path returned the referenced column UNAGGREGATED (a bare column in a
grouped statement — by SQL's own rules an error on PostgreSQL and an arbitrary row's
value on SQLite), and the ObjectQL path refused the measure. The six aggregates — `count`, `sum`,
`avg`, `min`, `max`, `count_distinct` — are unchanged and are now the whole
vocabulary.

### FROM → TO

| removed | what to write instead |
| --- | --- |
| `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`. |
| 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. |
| a measure whose old expression combined measures (a ratio, a difference) | `derived: { op, of: [...] }` on an ADR-0021 dataset over the same object. |

**The one-line fix: give the measure an aggregate type.** There is no mechanical
rewrite — the column alone does not say whether `amount` meant its sum, its average
or its largest value — so `os migrate meta` lists nothing for this change.

Each retired member is refused at parse with a prescription naming the six
aggregates, at the measure's `type`, and in `tsc` (the members are gone from the
`AggregationMetricType` type). A value the enum never declared keeps zod's own
message.

### The retirement kit

- **Value-level retirement.** `AggregationMetricType` is declared through
`enumWithRetiredValues` (`shared/retired-key.ts`), with the prescriptions
module-private. No authorable KEY and no def changed, so nothing lands in
`RETIRED_KEYS_BY_MAJOR`, and the four surface ratchets (`api-surface`,
`authorable-surface`, `json-schema.manifest`, `api-surface-signatures`) are
byte-identical.
- **No D2 conversion, by design.** A stored or built cube that still carries one of
the three is REFUSED, never rewritten or dropped: the boot door
(`ObjectStackDefinitionSchema`, which a built artifact is parsed through), the
`analytics_cube` write door and `defineStack` refuse it with the prescription, and
the rehydration seam replays no conversion over it.
- **D3 entry `cube-metric-expression-types-retired`**, with its step-18 rationale
fragment, carries the judgement the upgrader owes: which aggregate each measure
meant.
- **Liveness.** The `analytics_cube` row `measures.type` stays `live`, re-verified
2026-10-02, with the narrowing recorded.
- **Docs.** The `data/analytics` reference page is regenerated.
- **No deprecation window**, per the project's startup-stage posture.

### Reach, measured

- This repository authors no cube measure of the three types outside tests:
`examples/**`, `packages/**` (the platform objects included) and the skills and
docs carry none. The showcase cube's `type: 'string'` entries are dimensions,
whose `DimensionType` is a separate enum and is unchanged.
- objectui at its pinned commit carries no `AggregationMetricType` mirror and no
cube measure of the three types.
- Out-of-repo authored cubes: NOT MEASURED.

Clause-②: no (narrowing)

<!-- adr-0087: registered cube-metric-expression-types-retired -->
9 changes: 3 additions & 6 deletions content/docs/references/data/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,6 @@ const result = AggregationMetricType.parse(data);
* `min`
* `max`
* `count_distinct`
* `number`
* `string`
* `boolean`


---
Expand Down Expand Up @@ -126,7 +123,7 @@ Type: `[string, string]`
| **title** | `string` | optional | |
| **description** | `string` | optional | |
| **sql** | `string` | ✅ | Base SQL statement or Table Name |
| **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`. |
| **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`. |
| **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`. |
| **joins** | `Record<string, { name: string }>` | optional | |
| **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. |
Expand All @@ -146,7 +143,7 @@ Type: `[string, string]`
| **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. |
| **label** | `string` | ✅ | Human readable label |
| **description** | `string` | optional | |
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | |
| **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 }`). |
| **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. |

Expand Down Expand Up @@ -219,7 +216,7 @@ Type: `[string, string]`
| **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. |
| **label** | `string` | ✅ | Human readable label |
| **description** | `string` | optional | |
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | |
| **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 }`). |
| **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. |

Expand Down
14 changes: 9 additions & 5 deletions packages/lint/src/validate-dataset-measure-aggregates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,8 +886,8 @@ describe('the cube leg — an analyticsCubes member the analytics door refuses i
* `shape`, as the two door modules state their rules — read off the spec's
* table and predicates, never a retyped list:
*
* - a `type` that is no row of the table (the expression metric types) is
* judged by neither door;
* - a `type` that is no row of the table (the retired custom-SQL metric
* types, which the schema refuses) is judged by neither door;
* - `count_distinct` — `structured-json-dimension-door.ts`: refused when the
* row refuses the type OR the declaration is multi-value;
* - every other row — `cube-measure-field-type-door.ts`: refused when the row
Expand Down Expand Up @@ -980,8 +980,11 @@ describe('the cube leg — every cube measure is judged by the aggregate × fiel
expect(accepted, `${type} accepts`).toBeGreaterThan(5);
}
}
// The expression metric types are in the population and outside the table.
expect(types.filter((t) => !rows.includes(t)).sort()).toEqual(['boolean', 'number', 'string']);
// Every cube measure `type` IS a row: the custom-SQL metric types that
// used to sit outside the table (`number` / `string` / `boolean`) were
// retired from `AggregationMetricType` (#21000), so the population and the
// table are one vocabulary.
expect(types.filter((t) => !rows.includes(t)).sort()).toEqual([]);
});

it('reads a relationship path on the object the last hop reaches — the lookup\'s reference, or the join the cube declares for it', () => {
Expand All @@ -1002,7 +1005,8 @@ describe('the cube leg — every cube measure is judged by the aggregate × fiel
it('never hands the predicate a guess: no type, a type outside the table, the row wildcard, an unresolved column', () => {
const silent = (measures: Record<string, unknown>, ledger: Record<string, unknown> = {}) =>
expect(validateDatasetMeasureAggregates(cubeStack({ measures }, {}, ledger))).toEqual([]);
// The expression metric types are outside the table's vocabulary (skip 5).
// The retired custom-SQL metric types are outside the table's vocabulary
// (skip 5): the schema refuses them, so this leg leaves them to it.
silent({ n: measure('number', 'name'), s: measure('string', 'name'), b: measure('boolean', 'name') });
// A prototype key is not a row.
silent({ p: measure('toString', 'name') });
Expand Down
6 changes: 3 additions & 3 deletions packages/lint/src/validate-dataset-measure-aggregates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@
* `cube-measure-field-type-door.ts` half, which reads the TYPE alone — the
* `multiple` flag moves only `count_distinct`, see above), and never
* `count`, which reads no value and accepts every type. A `type` outside
* the table's vocabulary — the expression metric types `number` /
* `string` / `boolean` — is skip 5, as on a dataset, and neither door
* judges it.
* the table's vocabulary — since the custom-SQL metric types `number` /
* `string` / `boolean` were retired (#21000), one the schema itself
* refuses — is skip 5, as on a dataset, and neither door judges it.
*
* How a cube names its column is read the way the door reads it
* (`analytics-service.ts`, `hop-object.ts`):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,25 @@
*
* ## Why this refusal is deliberately NOT in the ADR-0112 envelope
*
* The reachable producer of a non-aggregate method — a custom-SQL measure
* (`AggregationMetricType` `number`/`string`/`boolean`) — is refused earlier
* and caller-facing by `ObjectQLStrategy.resolveMeasureAggregation` (commit 017130a09,
* `INVALID_FIELD` / 400). Anything still arriving at the bridge is host drift
* (an unparsed cube object, our own drift), which `dataset-refusal.ts`'s module
* header assigns to the bare-`Error`, undeclared-500 tier — the same tier it
* assigns to `native-sql-strategy.ts`'s "measure … has unrecognised type". The
* absence of a `code` is therefore asserted, not overlooked: enveloping this as
* a 400 would tell the author to fix something they did not write.
* A non-aggregate method is host drift (an unparsed cube object, our own
* drift), which `dataset-refusal.ts`'s module header assigns to the
* bare-`Error`, undeclared-500 tier. The absence of a `code` is therefore
* asserted, not overlooked: enveloping this as a 400 would tell the author to
* fix something they did not write.
*
* ## [#21000] Two seams, one tier
*
* The custom-SQL metric types (`AggregationMetricType` `number` / `string` /
* `boolean`) used to be refused `INVALID_FIELD` / 400 by
* `ObjectQLStrategy.resolveMeasureAggregation` (commit 017130a09), and every
* OTHER non-aggregate type — `median`, a cube that never met the parse — was
* forwarded on to this bridge. The three were retired from the spec, and the
* resolver now answers every type no aggregate lowers itself
* (`aggregateOfMeasure`), in the same undeclared-500 tier, in the spec's words.
* So the cube path no longer reaches the bridge with one, and this file pins
* both seams: the cube path refused at the resolver, nothing reaching the
* engine; and the bridge itself, driven directly, still parsing whatever
* method arrives before the engine sees it.
*/

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

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

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

expect(err).toBeInstanceOf(Error);
expect(err?.message).toContain('measure "revenue" on cube "sales" cannot be served: its type "median"');
for (const fn of AggregationFunction.options) expect(err?.message).toContain(fn);
// Undeclared-500 tier, deliberately: no ADR-0112 envelope on this family.
expect((err as Error & { code?: string }).code).toBeUndefined();
// The load-bearing half — the bad type never reached the engine.
expect(calls).toHaveLength(0);
});

it('the bridge itself still refuses a method outside the engine vocabulary instead of forwarding it', async () => {
// Driven DIRECTLY: no cube path reaches the bridge with a non-aggregate
// method any more (the case above), so the seam is exercised as the
// strategy calls it — the service's strategy context's `executeAggregate`,
// which is the plugin's auto-bridge — with a method the engine does not
// declare.
const calls: EngineAggregateCall[] = [];
const service = await analyticsVia(fakeEngine(calls, schema), cubeWithMeasureType('sum'));
const bridge = (service as unknown as {
baseCtx: { executeAggregate: (object: string, options: unknown) => Promise<unknown> };
}).baseCtx.executeAggregate;

const err = await bridge('opportunity', {
groupBy: ['region'],
aggregations: [{ field: 'amount', method: 'median', alias: 'revenue' }],
}).then(() => null, (e: Error) => e);

expect(err).toBeInstanceOf(Error);
// The wording IS the contract here: it must name the offending method, the
// aggregation it belongs to, and the legal vocabulary.
Expand Down
Loading
Loading