Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
e43c8d6
feat(spec)!: retire the inner name on cube measures and dimensions — …
claude Sep 28, 2026
a24bbc2
feat(spec,service-analytics): producers, fixtures, retirement pin and…
claude Sep 28, 2026
c1cae40
test(spec): the absence pin's first specimen sits inside an object li…
claude Sep 28, 2026
3e01ac5
docs(skills): the cube example drops the inner member name — deletion…
claude Sep 28, 2026
ca293fc
Merge remote-tracking branch 'origin/main' into claude/issue-20300-cu…
claude Sep 28, 2026
e196281
chore(spec): regenerate the analytics reference page from the merged …
claude Sep 28, 2026
14cac89
test: computed cube-member builders stop writing the retired inner name
claude Sep 28, 2026
c4771e6
chore(changeset): the fixture census counts the map-built members too
claude Sep 28, 2026
2252e57
docs(changeset): Clause-② is no (narrowing) — the retirement widens n…
claude Sep 28, 2026
5ce26b0
Merge remote-tracking branch 'origin/main' into claude/issue-20300-cu…
claude Sep 28, 2026
f639af5
fix(spec): stamp retiredAfter on cube-member-inner-name-removed — unp…
claude Sep 28, 2026
40f1f68
Merge remote-tracking branch 'origin/main' into claude/issue-20300-cu…
claude Sep 29, 2026
2bd3b8d
chore(spec): regenerate the authorable surface from the merged tree
claude Sep 29, 2026
f694896
test: the $empty operator fixtures main brought stop writing the reti…
claude Sep 29, 2026
d977ee6
Merge remote-tracking branch 'origin/main' into claude/issue-20300-cu…
claude Sep 29, 2026
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
46 changes: 46 additions & 0 deletions .changeset/20300-cube-member-inner-name-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@objectstack/spec': minor
'@objectstack/service-analytics': patch
---

**BREAKING** — the inner `name` on an analytics cube's measures and dimensions (`MetricSchema.name`, `DimensionSchema.name`) is now refused at parse: nothing ever read it. The record key a member is declared under IS its name — the analytics API publishes it as `<cube>.<key>` and a query names it that way. Delete the inner `name`; to rename a member, rename its key.

Clause-②: no (narrowing)

`measures` and `dimensions` are records, and the key was always the member's identity: `GET /api/v1/analytics/meta` publishes every member as `${cube.name}.${key}` (in `@objectstack/service-analytics` and in `@objectstack/driver-memory`), and both SQL strategies and the in-memory driver resolve a member by indexing the bag with that key. Measured before removal, with a lit control: zero reads of a member's inner `name` in non-test source, against four reads of the neighbouring `measure.label` / `dimension.label` in the same two `getMeta` projections. So the inner `name` was a REQUIRED second copy of the identity that nothing read — and one that disagreed with its key was silently ignored (this repository's own in-memory driver fixtures authored `totalAmount: { name: 'total_amount', … }` and queried `orders.totalAmount`).

**Removed rather than enforced** (ADR-0049 enforce-or-remove; the triage verdict on the card, by the maintainer's criterion for declared-but-unenforced families): Cube.dev and LookML key a member by its declared name, with no second inner name that can disagree — and here the record key already delivered it.

## FROM → TO

| you wrote (17.4 and earlier) | write instead |
| --- | --- |
| `measures: { total_amount: { name: 'total_amount', label: 'Total', type: 'sum', sql: 'amount' } }` | `measures: { total_amount: { label: 'Total', type: 'sum', sql: 'amount' } }` |
| `dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } }` | `dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } }` |
| an inner `name` that DIFFERS from its key, e.g. `totalAmount: { name: 'total_amount', … }` | nothing changes at runtime — `orders.totalAmount` was already the name every query used. Delete the inner `name`, or, if `total_amount` is the name you meant, re-key the member and update every query, dashboard and report that names `orders.totalAmount` |

**The one-line fix:** delete `name` from every metric and dimension; the key it is declared under is its name.

**What an author who still writes it sees.** `tsc` fails at the authoring site (`Metric` / `Dimension` type the key `never`), and the parse — `defineCube()`, `defineStack({ analyticsCubes })`, `PUT /api/v1/meta/analytics_cube/:name` — refuses it at `measures.<key>.name` / `dimensions.<key>.name` with the prescription:

> `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. … 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.

`os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand.

## The retirement kit

- **`retiredKey()` tombstones, not a bare deletion** — even though both member shapes are `strictObject`s (the `action.aria` posture). A bare delete would still be loud, but only as a generic unrecognized-key report that cannot carry the prescription; the tombstone types the key `never` for `tsc` and raises the upgrade text at parse. The keys therefore stay in the walked shape: both liveness rows stay `dead` with a `REMOVED` note, and the authorable-surface baseline marks `data/Metric:name` and `data/Dimension:name` `[RETIRED]`.
- **The D2 conversion `cube-member-inner-name-removed`** (protocol 18, retired from the load path) deletes the inner `name` from every metric and dimension of every `analyticsCubes[]` entry. It is owed because the key was REQUIRED, so every stored or built cube carries it. It strips a disagreeing value too — the key already won everywhere, so no query or discovery answer changes — and its notice prints both spellings (`from: name "total_amount"`, `to: (removed; the record key "totalAmount" is the name)`). Its D3 record is the semantic entry `cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling they meant.
- **The producers stop writing it** (`@objectstack/service-analytics`): the dataset compiler (`compileDataset`), `CubeRegistry.inferFromObject` and the ad-hoc query mint no longer put an inner `name` on the members of the cubes they build. The members are filed under the same keys as before, so `/analytics/meta`, `/analytics/query` and `/analytics/sql` answer exactly as they did. The package README's cube example is corrected.
- **The `measures` / `dimensions` descriptions now say it**: "keyed by metric name: the record key IS the metric's name, published and queried as `<cube>.<key>`".

## Reach, measured

- This repository, non-test: the showcase cube (`examples/app-showcase`, 8 members), the published `objectstack-ui` skill's `defineCube` example (6), the `service-analytics` README (3), and the three internal cube mints above — every one wrote the inner `name` EQUAL to its key, and all are corrected here. Test fixtures: about 300 member literals and map-built members across 84 test and fixture files in eight packages, all EQUAL to their key except 21 in `driver-memory`, which disagreed (camelCase key, snake_case inner name) and were already queried by key.
- Out-of-repo authors: NOT MEASURED.

## What an operator with a STORED cube sees

A `sys_metadata` `analytics_cube` row or a built artifact written before this release carries the inner `name` on every member. Nothing breaks at read: the conversion replays on rehydration and at the artifact door and strips it, so the cube is served canonical and parses. `os migrate meta --stored --apply` rewrites the rows.

<!-- adr-0087: registered cube-member-inner-name-removed, cube-member-inner-name-retired -->
12 changes: 6 additions & 6 deletions content/docs/references/data/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ Type: `[string, string]`
| **title** | `string` | optional | |
| **description** | `string` | optional | |
| **sql** | `string` | ✅ | Base SQL statement or Table Name |
| **measures** | `Record<string, { name: string; label: string; description?: string; type: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>; … }>` | ✅ | Quantitative metrics |
| **dimensions** | `Record<string, { name: string; label: string; description?: string; type: Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>; … }>` | ✅ | Qualitative attributes |
| **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`. |
| **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** | `{ every?: string; sql?: string }` | optional | |
| **public** | `boolean` | optional (default: `true`) | Whether the analytics API exposes this cube. Default true (visible). false hides it from GET /analytics/meta and refuses POST /analytics/query and /analytics/sql for it (CUBE_NOT_FOUND). Visibility only: the underlying object's permissions and row-level security still govern its records on every door. |
Expand All @@ -143,7 +143,7 @@ Type: `[string, string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Unique metric ID |
| **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'>` | ✅ | |
Expand All @@ -154,7 +154,7 @@ Type: `[string, string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Unique dimension ID |
| **name** | `never` | optional | [REMOVED] `dimensions.<dimension>.name` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it never had an effect: the record key is the dimension's name. Every consumer resolves a dimension by its key in `dimensions` (`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 dimension, rename its key in `dimensions` — 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<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>` | ✅ | |
Expand Down Expand Up @@ -194,7 +194,7 @@ Type: `[string, string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Unique dimension ID |
| **name** | `never` | optional | [REMOVED] `dimensions.<dimension>.name` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it never had an effect: the record key is the dimension's name. Every consumer resolves a dimension by its key in `dimensions` (`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 dimension, rename its key in `dimensions` — 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<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>` | ✅ | |
Expand Down Expand Up @@ -223,7 +223,7 @@ Type: `[string, string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Unique metric ID |
| **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'>` | ✅ | |
Expand Down
8 changes: 0 additions & 8 deletions examples/app-showcase/src/data/analytics/showcase.cube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,21 @@ export const DeliveryCube = defineCube({
sql: 'showcase_task',
measures: {
count: {
name: 'count',
label: 'Task Count',
type: 'count',
sql: '*',
},
total_estimate_hours: {
name: 'total_estimate_hours',
label: 'Total Estimated Hours',
type: 'sum',
sql: 'estimate_hours',
},
avg_estimate_hours: {
name: 'avg_estimate_hours',
label: 'Average Estimate (h)',
type: 'avg',
sql: 'estimate_hours',
},
done_rate: {
name: 'done_rate',
label: 'Done Rate (%)',
type: 'number',
sql: "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)",
Expand All @@ -48,25 +44,21 @@ export const DeliveryCube = defineCube({
},
dimensions: {
status: {
name: 'status',
label: 'Status',
type: 'string',
sql: 'status',
},
priority: {
name: 'priority',
label: 'Priority',
type: 'string',
sql: 'priority',
},
due_date: {
name: 'due_date',
label: 'Due Date',
type: 'time',
sql: 'due_date',
},
assignee: {
name: 'assignee',
label: 'Assignee',
type: 'string',
sql: 'assignee',
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/analytics-automation-json-erasure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,10 @@ const ACCOUNT_CUBE: Cube = {
title: 'Accounts',
sql: 'crm_account',
measures: {
account_count: { name: 'account_count', label: 'Account count', type: 'count', sql: '*' },
account_count: { label: 'Account count', type: 'count', sql: '*' },
},
dimensions: {
industry: { name: 'industry', label: 'Industry', type: 'string', sql: 'industry' },
industry: { label: 'Industry', type: 'string', sql: 'industry' },
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,10 @@ describe('[#20444] InMemoryDriver — $empty on the live path, the reference mat
name: TABLE,
title: 'empty',
sql: TABLE,
measures: { count: { name: 'count', label: 'Rows', type: 'count', sql: 'id' } },
measures: { count: { label: 'Rows', type: 'count', sql: 'id' } },
dimensions: {
id: { name: 'id', label: 'id', type: 'string', sql: 'id' },
title: { name: 'title', label: 'title', type: 'string', sql: 'title' },
id: { label: 'id', type: 'string', sql: 'id' },
title: { label: 'title', type: 'string', sql: 'title' },
},
public: true,
} as Cube;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,11 @@ describe('[#6814] the analytics face answers count_distinct the same number', ()
title: 'Agg',
sql: TABLE,
measures: {
distinctStage: { name: 'distinct_stage', label: 'Distinct stage', type: 'count_distinct', sql: 'stage' },
distinctScore: { name: 'distinct_score', label: 'Distinct score', type: 'count_distinct', sql: 'score' },
distinctStage: { label: 'Distinct stage', type: 'count_distinct', sql: 'stage' },
distinctScore: { label: 'Distinct score', type: 'count_distinct', sql: 'score' },
},
dimensions: {
region: { name: 'region', label: 'Region', type: 'string', sql: 'region' },
region: { label: 'Region', type: 'string', sql: 'region' },
},
} as unknown as Cube;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ const CUBE: Cube = {
name: 'events',
title: 'Events',
sql: 'events',
measures: { count: { name: 'count', label: 'Count', type: 'count', sql: 'id' } },
measures: { count: { label: 'Count', type: 'count', sql: 'id' } },
dimensions: {
probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' },
probe: { label: 'Probe', type: 'string', sql: 'probe' },
createdAt: {
name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at',
label: 'Created At', type: 'time', sql: 'created_at',
granularities: ['day'],
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ const CUBE: Cube = {
name: 'events',
title: 'Events',
sql: 'events',
measures: { count: { name: 'count', label: 'Count', type: 'count', sql: 'id' } },
measures: { count: { label: 'Count', type: 'count', sql: 'id' } },
dimensions: {
probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' },
probe: { label: 'Probe', type: 'string', sql: 'probe' },
createdAt: {
name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at',
label: 'Created At', type: 'time', sql: 'created_at',
granularities: ['day'],
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,11 @@ const CUBE: Cube = {
title: 'Events',
sql: 'events',
measures: {
count: { name: 'count', label: 'Count', type: 'count', sql: 'id' },
count: { label: 'Count', type: 'count', sql: 'id' },
},
dimensions: {
probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' },
probe: { label: 'Probe', type: 'string', sql: 'probe' },
createdAt: {
name: 'created_at',
label: 'Created At',
type: 'time',
sql: 'created_at',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,11 @@ const CUBE: Cube = {
title: 'Events',
sql: 'events',
measures: {
count: { name: 'count', label: 'Count', type: 'count', sql: 'id' },
count: { label: 'Count', type: 'count', sql: 'id' },
},
dimensions: {
probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' },
probe: { label: 'Probe', type: 'string', sql: 'probe' },
createdAt: {
name: 'created_at',
label: 'Created At',
type: 'time',
sql: 'created_at',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,11 @@ const CUBE: Cube = {
title: 'Events',
sql: 'events',
measures: {
count: { name: 'count', label: 'Count', type: 'count', sql: 'id' },
count: { label: 'Count', type: 'count', sql: 'id' },
},
dimensions: {
probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' },
probe: { label: 'Probe', type: 'string', sql: 'probe' },
createdAt: {
name: 'created_at',
label: 'Created At',
type: 'time',
sql: 'created_at',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,11 @@ const CUBE: Cube = {
title: 'Events',
sql: 'events',
measures: {
count: { name: 'count', label: 'Count', type: 'count', sql: 'id' },
count: { label: 'Count', type: 'count', sql: 'id' },
},
dimensions: {
probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' },
probe: { label: 'Probe', type: 'string', sql: 'probe' },
createdAt: {
name: 'created_at',
label: 'Created At',
type: 'time',
sql: 'created_at',
Expand Down
Loading
Loading