diff --git a/.changeset/20300-cube-member-inner-name-retired.md b/.changeset/20300-cube-member-inner-name-retired.md new file mode 100644 index 00000000000..b1d632e5c7d --- /dev/null +++ b/.changeset/20300-cube-member-inner-name-retired.md @@ -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 `.` 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..name` / `dimensions..name` with the prescription: + +> `measures..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 `.`. 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 `.`". + +## 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. + + diff --git a/content/docs/references/data/analytics.mdx b/content/docs/references/data/analytics.mdx index cc64d3c013a..1efc5e7e1f7 100644 --- a/content/docs/references/data/analytics.mdx +++ b/content/docs/references/data/analytics.mdx @@ -126,8 +126,8 @@ Type: `[string, string]` | **title** | `string` | optional | | | **description** | `string` | optional | | | **sql** | `string` | ✅ | Base SQL statement or Table Name | -| **measures** | `Record; … }>` | ✅ | Quantitative metrics | -| **dimensions** | `Record; … }>` | ✅ | Qualitative attributes | +| **measures** | `Record; sql: string; … }>` | ✅ | Quantitative metrics, keyed by metric name: the record key IS the metric's name, published and queried as `.`. A metric declares no inner `name`. | +| **dimensions** | `Record; sql: string; … }>` | ✅ | Qualitative attributes, keyed by dimension name: the record key IS the dimension's name, published and queried as `.`. A dimension declares no inner `name`. | | **joins** | `Record` | 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. | @@ -143,7 +143,7 @@ Type: `[string, string]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique metric ID | +| **name** | `never` | optional | [REMOVED] `measures..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 `.`, 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 `.`. 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'>` | ✅ | | @@ -154,7 +154,7 @@ Type: `[string, string]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique dimension ID | +| **name** | `never` | optional | [REMOVED] `dimensions..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 `.`, 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 `.`. 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'>` | ✅ | | @@ -194,7 +194,7 @@ Type: `[string, string]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique dimension ID | +| **name** | `never` | optional | [REMOVED] `dimensions..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 `.`, 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 `.`. 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'>` | ✅ | | @@ -223,7 +223,7 @@ Type: `[string, string]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique metric ID | +| **name** | `never` | optional | [REMOVED] `measures..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 `.`, 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 `.`. 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'>` | ✅ | | diff --git a/examples/app-showcase/src/data/analytics/showcase.cube.ts b/examples/app-showcase/src/data/analytics/showcase.cube.ts index fe8bb4cca28..1293034af4d 100644 --- a/examples/app-showcase/src/data/analytics/showcase.cube.ts +++ b/examples/app-showcase/src/data/analytics/showcase.cube.ts @@ -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(*)", @@ -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', diff --git a/packages/client/src/analytics-automation-json-erasure.test.ts b/packages/client/src/analytics-automation-json-erasure.test.ts index e966780b411..de6fec331fb 100644 --- a/packages/client/src/analytics-automation-json-erasure.test.ts +++ b/packages/client/src/analytics-automation-json-erasure.test.ts @@ -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' }, }, }; diff --git a/packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts b/packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts index d409a025717..e88324d48b1 100644 --- a/packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts +++ b/packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts @@ -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; diff --git a/packages/drivers/driver-memory/src/memory-aggregation-conformance.test.ts b/packages/drivers/driver-memory/src/memory-aggregation-conformance.test.ts index e3d2e58acb4..f30d002c956 100644 --- a/packages/drivers/driver-memory/src/memory-aggregation-conformance.test.ts +++ b/packages/drivers/driver-memory/src/memory-aggregation-conformance.test.ts @@ -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; diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-array-arity.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-array-arity.test.ts index 87983f5d556..be13dbd4d1f 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-array-arity.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-array-arity.test.ts @@ -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'], }, }, diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-conformance.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-conformance.test.ts index 0152b82868a..980d1a7067c 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-conformance.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-conformance.test.ts @@ -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'], }, }, diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts index 027a7ea7d24..7618dbd6367 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts @@ -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', diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts index 64dccf0dfcb..ed6f64c0a52 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts @@ -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', diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-token-end-exclusive.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-token-end-exclusive.test.ts index 9a134cdc2c8..9a0d702b222 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-token-end-exclusive.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-token-end-exclusive.test.ts @@ -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', diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts index db550461ab3..87f01ede2af 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts @@ -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', diff --git a/packages/drivers/driver-memory/src/memory-analytics-echo-operator-coverage.test.ts b/packages/drivers/driver-memory/src/memory-analytics-echo-operator-coverage.test.ts index 92b2d2c1851..e550a84b4a8 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-echo-operator-coverage.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-echo-operator-coverage.test.ts @@ -133,11 +133,11 @@ const CUBE: Cube = { name: 'deals', title: 'Deals', sql: 'deal', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: 'id' } }, + measures: { total: { label: 'Total', type: 'count', sql: 'id' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, - amount: { name: 'amount', label: 'Amount', type: 'number', sql: 'amount' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + name: { label: 'Name', type: 'string', sql: 'name' }, + amount: { label: 'Amount', type: 'number', sql: 'amount' }, }, public: true, }; @@ -399,10 +399,10 @@ describe('[#7117] the analytics echo renders the query it describes', () => { ]) await temporal.create('ev', { ...r }); const cube: Cube = { name: 'evs', title: 'Evs', sql: 'ev', - measures: { total: { name: 'total', label: 'T', type: 'count', sql: 'id' } }, + measures: { total: { label: 'T', type: 'count', sql: 'id' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - at: { name: 'at', label: 'At', type: 'time', sql: 'at' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + at: { label: 'At', type: 'time', sql: 'at' }, }, public: true, }; diff --git a/packages/drivers/driver-memory/src/memory-analytics-filter-refusal.test.ts b/packages/drivers/driver-memory/src/memory-analytics-filter-refusal.test.ts index 1c7a674d404..0d5cedfaa44 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-filter-refusal.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-filter-refusal.test.ts @@ -48,15 +48,15 @@ const CUBE: Cube = { title: 'Deals', sql: 'deals', measures: { - count: { name: 'count', label: 'Deal Count', type: 'count', sql: 'id' }, - totalAmount: { name: 'total_amount', label: 'Total', type: 'sum', sql: 'amount' }, + count: { label: 'Deal Count', type: 'count', sql: 'id' }, + totalAmount: { label: 'Total', type: 'sum', sql: 'amount' }, }, dimensions: { - stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, - owner: { name: 'owner', label: 'Owner', type: 'string', sql: 'owner' }, - name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, - amount: { name: 'amount', label: 'Amount', type: 'number', sql: 'amount' }, - closedAt: { name: 'closed_at', label: 'Closed At', type: 'time', sql: 'closed_at' }, + stage: { label: 'Stage', type: 'string', sql: 'stage' }, + owner: { label: 'Owner', type: 'string', sql: 'owner' }, + name: { label: 'Name', type: 'string', sql: 'name' }, + amount: { label: 'Amount', type: 'number', sql: 'amount' }, + closedAt: { label: 'Closed At', type: 'time', sql: 'closed_at' }, }, public: true, }; diff --git a/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts b/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts index 38cd944b2b9..3c7bd4709d7 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts @@ -34,12 +34,11 @@ const cubes: Cube[] = [ title: 'Events', sql: 'events', measures: { - count: { name: 'count', label: 'Event Count', type: 'count', sql: 'id' }, - latest: { name: 'latest', label: 'Latest Event', type: 'max', sql: 'created_at' }, + count: { label: 'Event Count', type: 'count', sql: 'id' }, + latest: { label: 'Latest Event', type: 'max', sql: 'created_at' }, }, dimensions: { createdAt: { - name: 'createdAt', label: 'Created At', type: 'time', sql: 'created_at', diff --git a/packages/drivers/driver-memory/src/memory-analytics.test.ts b/packages/drivers/driver-memory/src/memory-analytics.test.ts index b73363ac606..e22113130b2 100644 --- a/packages/drivers/driver-memory/src/memory-analytics.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics.test.ts @@ -54,19 +54,16 @@ describe('MemoryAnalyticsService', () => { sql: 'orders', measures: { count: { - name: 'count', label: 'Order Count', type: 'count', sql: 'id' }, totalAmount: { - name: 'total_amount', label: 'Total Amount', type: 'sum', sql: 'amount' }, avgAmount: { - name: 'avg_amount', label: 'Average Amount', type: 'avg', sql: 'amount' @@ -74,19 +71,16 @@ describe('MemoryAnalyticsService', () => { }, dimensions: { customer: { - name: 'customer', label: 'Customer', type: 'string', sql: 'customer' }, status: { - name: 'status', label: 'Status', type: 'string', sql: 'status' }, createdAt: { - name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at', @@ -101,19 +95,16 @@ describe('MemoryAnalyticsService', () => { sql: 'products', measures: { count: { - name: 'count', label: 'Product Count', type: 'count', sql: 'id' }, avgPrice: { - name: 'avg_price', label: 'Average Price', type: 'avg', sql: 'price' }, totalStock: { - name: 'total_stock', label: 'Total Stock', type: 'sum', sql: 'stock' @@ -121,13 +112,11 @@ describe('MemoryAnalyticsService', () => { }, dimensions: { category: { - name: 'category', label: 'Category', type: 'string', sql: 'category' }, name: { - name: 'name', label: 'Product Name', type: 'string', sql: 'name' @@ -321,10 +310,10 @@ describe('MemoryAnalyticsService', () => { title: 'Opps', sql: 'orders', measures: { - amount: { name: 'amount', label: 'Amount', type: 'sum', sql: 'amount' }, + amount: { label: 'Amount', type: 'sum', sql: 'amount' }, }, dimensions: { - status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, + status: { label: 'Status', type: 'string', sql: 'status' }, }, }); const aliasService = new MemoryAnalyticsService({ driver, cubes: [aliasCube] }); diff --git a/packages/drivers/driver-memory/src/memory-boolean-aggregand.test.ts b/packages/drivers/driver-memory/src/memory-boolean-aggregand.test.ts index 974c39898b2..e4ca58b5bbd 100644 --- a/packages/drivers/driver-memory/src/memory-boolean-aggregand.test.ts +++ b/packages/drivers/driver-memory/src/memory-boolean-aggregand.test.ts @@ -259,15 +259,15 @@ describe('[#11065] the analytics face answers the same rate', () => { title: 'Cases', sql: TABLE, measures: { - slaViolationRate: { name: 'sla_violation_rate', label: 'SLA Violation Rate', type: 'avg', sql: 'is_sla_violated' }, - slaViolations: { name: 'sla_violations', label: 'SLA Violations', type: 'sum', sql: 'is_sla_violated' }, - minViolated: { name: 'min_violated', label: 'Min violated', type: 'min', sql: 'is_sla_violated' }, - maxViolated: { name: 'max_violated', label: 'Max violated', type: 'max', sql: 'is_sla_violated' }, - count: { name: 'count', label: 'Cases', type: 'count', sql: 'id' }, - avgNote: { name: 'avg_note', label: 'Avg note', type: 'avg', sql: 'note' }, + slaViolationRate: { label: 'SLA Violation Rate', type: 'avg', sql: 'is_sla_violated' }, + slaViolations: { label: 'SLA Violations', type: 'sum', sql: 'is_sla_violated' }, + minViolated: { label: 'Min violated', type: 'min', sql: 'is_sla_violated' }, + maxViolated: { label: 'Max violated', type: 'max', sql: 'is_sla_violated' }, + count: { label: 'Cases', type: 'count', sql: 'id' }, + avgNote: { label: 'Avg note', type: 'avg', sql: 'note' }, }, dimensions: { - isClosed: { name: 'is_closed', label: 'Closed', type: 'boolean', sql: 'is_closed' }, + isClosed: { label: 'Closed', type: 'boolean', sql: 'is_closed' }, }, } as unknown as Cube; diff --git a/packages/drivers/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts b/packages/drivers/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts index b4899e25874..9bf2cf935e5 100644 --- a/packages/drivers/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts +++ b/packages/drivers/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts @@ -112,10 +112,10 @@ describe('MemoryAnalyticsService — dateRange window (#4042)', () => { title: 'Tasks', sql: 'task', measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: 'id' }, + count: { label: 'Count', type: 'count', sql: 'id' }, }, dimensions: { - created_at: { name: 'created_at', label: 'Created', type: 'time', sql: 'created_at' }, + created_at: { label: 'Created', type: 'time', sql: 'created_at' }, }, } as unknown as Cube; diff --git a/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts b/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts index 936b3bb41b2..79369e9fcd0 100644 --- a/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts +++ b/packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts @@ -106,11 +106,11 @@ const CONFORMANCE_CUBE: Cube = { name: TABLE, title: 'Filter logic conformance', sql: TABLE, - measures: { count: { name: 'count', label: 'Rows', type: 'count', sql: 'id' } }, + measures: { count: { label: 'Rows', type: 'count', sql: 'id' } }, dimensions: Object.fromEntries( (['id', 'a', 'b', 'c', 'd', 'owner', 'status', 'parent_object', 'parent_id'] as const).map((f) => [ f, - { name: f, label: f, type: 'string' as const, sql: f }, + { label: f, type: 'string' as const, sql: f }, ]), ), public: true, @@ -314,9 +314,9 @@ const COMPARAND_CUBE: Cube = { name: COMPARAND_TABLE, title: 'Comparand round-trip', sql: COMPARAND_TABLE, - measures: { count: { name: 'count', label: 'Rows', type: 'count', sql: 'id' } }, + measures: { count: { label: 'Rows', type: 'count', sql: 'id' } }, dimensions: Object.fromEntries( - Object.keys(COMPARAND_FIELDS).map((f) => [f, { name: f, label: f, type: 'string' as const, sql: f }]), + Object.keys(COMPARAND_FIELDS).map((f) => [f, { label: f, type: 'string' as const, sql: f }]), ), public: true, }; diff --git a/packages/drivers/driver-memory/src/memory-exists-has-value-faces.test.ts b/packages/drivers/driver-memory/src/memory-exists-has-value-faces.test.ts index 2bdf8e7918a..711ecbb4103 100644 --- a/packages/drivers/driver-memory/src/memory-exists-has-value-faces.test.ts +++ b/packages/drivers/driver-memory/src/memory-exists-has-value-faces.test.ts @@ -116,10 +116,10 @@ const CUBE = { name: 'deals', title: 'Deals', sql: 't', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: 'id' } }, + measures: { total: { label: 'Total', type: 'count', sql: 'id' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + name: { label: 'Name', type: 'string', sql: 'name' }, }, public: true, } as never; diff --git a/packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts b/packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts index adfd5fb31d2..c0ca4aa85b6 100644 --- a/packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts +++ b/packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts @@ -215,14 +215,14 @@ describe('[#6682] the analytics face answers the same text rules', () => { title: 'Texts', sql: TABLE, measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: 'id' }, + count: { label: 'Count', type: 'count', sql: 'id' }, }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + name: { label: 'Name', type: 'string', sql: 'name' }, // [#14079] The fixture's non-string column, declared as the number it is // so the `score` rows reach this face through its own vocabulary. - score: { name: 'score', label: 'Score', type: 'number', sql: 'score' }, + score: { label: 'Score', type: 'number', sql: 'score' }, }, } as unknown as Cube; diff --git a/packages/drivers/driver-memory/src/memory-operator-key-clobber.test.ts b/packages/drivers/driver-memory/src/memory-operator-key-clobber.test.ts index 80a4f9ef48c..80adefd4324 100644 --- a/packages/drivers/driver-memory/src/memory-operator-key-clobber.test.ts +++ b/packages/drivers/driver-memory/src/memory-operator-key-clobber.test.ts @@ -308,10 +308,10 @@ const CUBE = { name: 'deals', title: 'Deals', sql: 't', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: 'id' } }, + measures: { total: { label: 'Total', type: 'count', sql: 'id' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + name: { label: 'Name', type: 'string', sql: 'name' }, }, public: true, } as never; diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 87a773ed417..a8b407862ee 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -1914,8 +1914,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { item: { name: 'orders', sql: 'orders', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, - dimensions: { stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, + dimensions: { stage: { label: 'Stage', type: 'string', sql: 'stage' } }, }, organizationId: 'org_alpha', }); diff --git a/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts b/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts index 1d6ab40f68f..5988eb794c4 100644 --- a/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts @@ -75,10 +75,10 @@ const OPEN_SUMMARY: Cube = { title: 'Open summary', sql: 'admission_open', measures: { - authored_total: { name: 'authored_total', label: 'Authored total', type: 'count', sql: '*' }, + authored_total: { label: 'Authored total', type: 'count', sql: '*' }, }, dimensions: { - region: { name: 'region', label: 'Region', type: 'string', sql: 'region' }, + region: { label: 'Region', type: 'string', sql: 'region' }, }, }; @@ -88,7 +88,7 @@ const WALLED_SUMMARY: Cube = { title: 'Walled summary', sql: 'admission_walled', measures: { - walled_total: { name: 'walled_total', label: 'Walled total', type: 'count', sql: '*' }, + walled_total: { label: 'Walled total', type: 'count', sql: '*' }, }, dimensions: {}, }; diff --git a/packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts b/packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts index 494dc45727b..2977376ef0d 100644 --- a/packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts @@ -61,10 +61,10 @@ const OPEN_SUMMARY: Cube = { title: 'Open summary', sql: 'admission_open', measures: { - authored_total: { name: 'authored_total', label: 'Authored total', type: 'count', sql: '*' }, + authored_total: { label: 'Authored total', type: 'count', sql: '*' }, }, dimensions: { - region: { name: 'region', label: 'Region', type: 'string', sql: 'region' }, + region: { label: 'Region', type: 'string', sql: 'region' }, }, }; @@ -74,7 +74,7 @@ const HIDDEN_SUMMARY: Cube = { title: 'Hidden summary', sql: 'admission_open', measures: { - hidden_total: { name: 'hidden_total', label: 'Hidden total', type: 'count', sql: '*' }, + hidden_total: { label: 'Hidden total', type: 'count', sql: '*' }, }, dimensions: {}, public: false, diff --git a/packages/qa/downstream-contract/src/additional-domains.fixtures.ts b/packages/qa/downstream-contract/src/additional-domains.fixtures.ts index 2d46f2037b1..af0aef5748a 100644 --- a/packages/qa/downstream-contract/src/additional-domains.fixtures.ts +++ b/packages/qa/downstream-contract/src/additional-domains.fixtures.ts @@ -113,10 +113,10 @@ export const DcCube: Cube = { description: 'Account analytics.', sql: 'dc_account', measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, + count: { label: 'Count', type: 'count', sql: '*' }, }, dimensions: { - stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, + stage: { label: 'Stage', type: 'string', sql: 'stage' }, }, }; diff --git a/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts b/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts index 9d8bbc07580..80a524aecd0 100644 --- a/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts +++ b/packages/runtime/src/cross-field-refusal-operand-withhold.test.ts @@ -125,7 +125,7 @@ const CUBE: Cube = { measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( ['id', 'amount', 'budget', 'stage', 'owner', 'starts_on', 'ends_on', 'organization_id'].map( - (n) => [n, { name: n, label: n, type: 'string', sql: n }], + (n) => [n, { label: n, type: 'string', sql: n }], ), ), public: true, diff --git a/packages/services/service-analytics/README.md b/packages/services/service-analytics/README.md index 3a59f339765..e58c394d7fb 100644 --- a/packages/services/service-analytics/README.md +++ b/packages/services/service-analytics/README.md @@ -28,11 +28,11 @@ const ordersCube: Cube = { title: 'Orders', sql: 'orders', measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, - total_amount: { name: 'total_amount', label: 'Total Amount', type: 'sum', sql: 'amount' }, + count: { label: 'Count', type: 'count', sql: '*' }, + total_amount: { label: 'Total Amount', type: 'sum', sql: 'amount' }, }, dimensions: { - status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, + status: { label: 'Status', type: 'string', sql: 'status' }, }, }; diff --git a/packages/services/service-analytics/src/__tests__/adhoc-query-request-scope.test.ts b/packages/services/service-analytics/src/__tests__/adhoc-query-request-scope.test.ts index d18d9e4f512..35f111297d5 100644 --- a/packages/services/service-analytics/src/__tests__/adhoc-query-request-scope.test.ts +++ b/packages/services/service-analytics/src/__tests__/adhoc-query-request-scope.test.ts @@ -70,10 +70,10 @@ const OPEN_SUMMARY: Cube = { title: 'Open summary', sql: OPEN, measures: { - authored_total: { name: 'authored_total', label: 'Authored total', type: 'count', sql: '*' }, + authored_total: { label: 'Authored total', type: 'count', sql: '*' }, }, dimensions: { - region: { name: 'region', label: 'Region', type: 'string', sql: 'region' }, + region: { label: 'Region', type: 'string', sql: 'region' }, }, }; @@ -83,7 +83,7 @@ const WALLED_SUMMARY: Cube = { title: 'Walled summary', sql: WALLED, measures: { - walled_total: { name: 'walled_total', label: 'Walled total', type: 'count', sql: '*' }, + walled_total: { label: 'Walled total', type: 'count', sql: '*' }, }, dimensions: {}, }; diff --git a/packages/services/service-analytics/src/__tests__/aggregate-bridge-function-vocabulary.test.ts b/packages/services/service-analytics/src/__tests__/aggregate-bridge-function-vocabulary.test.ts index 8e1cb40dd24..ba9c058e421 100644 --- a/packages/services/service-analytics/src/__tests__/aggregate-bridge-function-vocabulary.test.ts +++ b/packages/services/service-analytics/src/__tests__/aggregate-bridge-function-vocabulary.test.ts @@ -82,8 +82,8 @@ const cubeWithMeasureType = (type: string): Cube => ({ name: 'sales', title: 'Sales', sql: 'opportunity', - measures: { revenue: { name: 'revenue', label: 'Revenue', type, sql: 'amount' } as Cube['measures'][string] }, - dimensions: { region: { name: 'region', label: 'Region', type: 'string', sql: 'region' } }, + measures: { revenue: { label: 'Revenue', type, sql: 'amount' } as Cube['measures'][string] }, + dimensions: { region: { label: 'Region', type: 'string', sql: 'region' } }, public: true, }); diff --git a/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts b/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts index 27d66467b76..24e23b9f689 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts @@ -203,8 +203,8 @@ describe('#16322 — this package\'s analytics faces conform to the shared dateR const PREVIEW_CUBE = { name: 'events', sql: 'events', - dimensions: { id: { name: 'id', type: 'string', sql: 'id' } }, - measures: { count: { name: 'count', type: 'count', sql: '*' } }, + dimensions: { id: { type: 'string', sql: 'id' } }, + measures: { count: { type: 'count', sql: '*' } }, } as unknown as Cube; /** Row ids the evaluator keeps for `dateRange`, grouped by `id` so rows ARE ids. */ diff --git a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts index f1dba3a3065..ad4aed32088 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts @@ -17,14 +17,13 @@ const ordersCube: Cube = { title: 'Orders', sql: 'orders', measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, - total_amount: { name: 'total_amount', label: 'Total Amount', type: 'sum', sql: 'amount' }, - avg_amount: { name: 'avg_amount', label: 'Avg Amount', type: 'avg', sql: 'amount' }, + count: { label: 'Count', type: 'count', sql: '*' }, + total_amount: { label: 'Total Amount', type: 'sum', sql: 'amount' }, + avg_amount: { label: 'Avg Amount', type: 'avg', sql: 'amount' }, }, dimensions: { - status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, + status: { label: 'Status', type: 'string', sql: 'status' }, created_at: { - name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at', @@ -177,18 +176,16 @@ describe('NativeSQLStrategy', () => { sql: 'opportunity', public: true, measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, + count: { label: 'Count', type: 'count', sql: '*' }, account_revenue: { - name: 'account_revenue', label: 'Account Revenue (Sum)', type: 'sum', sql: 'account.annual_revenue', }, }, dimensions: { - stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, + stage: { label: 'Stage', type: 'string', sql: 'stage' }, account_industry: { - name: 'account_industry', label: 'Industry', type: 'string', sql: 'account.industry', @@ -228,12 +225,12 @@ describe('NativeSQLStrategy', () => { sql: 'opportunity', public: true, measures: { - amount_sum: { name: 'amount_sum', label: 'Amount (Sum)', type: 'sum', sql: 'amount' }, + amount_sum: { label: 'Amount (Sum)', type: 'sum', sql: 'amount' }, }, dimensions: { // Frontend will send `account.industry`; cube key uses underscore. account_industry: { - name: 'account_industry', label: 'Industry', type: 'string', sql: 'account.industry', + label: 'Industry', type: 'string', sql: 'account.industry', }, }, }; diff --git a/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts index 5b6efb53e95..9c93b5032b0 100644 --- a/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts +++ b/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts @@ -82,7 +82,7 @@ const CUBE: Cube = { measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( ['id', 'amount', 'budget', 'stage', 'owner', 'starts_on', 'ends_on', 'organization_id'].map( - (n) => [n, { name: n, label: n, type: 'string', sql: n }], + (n) => [n, { label: n, type: 'string', sql: n }], ), ), public: true, diff --git a/packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts index 912ddb5e74f..01d4352b6c1 100644 --- a/packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts @@ -124,7 +124,7 @@ const CUBE: Cube = { sql: OBJECT, measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( - Object.keys(CROSS_FIELD_OFFSET_OBJECT_FIELDS).map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), + Object.keys(CROSS_FIELD_OFFSET_OBJECT_FIELDS).map((n) => [n, { label: n, type: 'string', sql: n }]), ), public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts index 24d019ff678..6034e969226 100644 --- a/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts @@ -150,11 +150,11 @@ const CUBE: Cube = { name: 'deals', title: 'Deals', sql: 'deal', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - amount: { name: 'amount', label: 'Amount', type: 'number', sql: 'amount' }, - budget: { name: 'budget', label: 'Budget', type: 'number', sql: 'budget' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + amount: { label: 'Amount', type: 'number', sql: 'amount' }, + budget: { label: 'Budget', type: 'number', sql: 'budget' }, }, public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/cube-inference-gate.test.ts b/packages/services/service-analytics/src/__tests__/cube-inference-gate.test.ts index f243a7dadde..6d42aad5f26 100644 --- a/packages/services/service-analytics/src/__tests__/cube-inference-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/cube-inference-gate.test.ts @@ -38,7 +38,7 @@ const authoredCube: Cube = { name: 'authored_cube', title: 'Authored', sql: 'some_physical_table', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, public: true, }; diff --git a/packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts b/packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts index ed3bbea4375..6e4b9a8a349 100644 --- a/packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts +++ b/packages/services/service-analytics/src/__tests__/cube-public-visibility.test.ts @@ -52,8 +52,8 @@ const silentLogger = { child: vi.fn().mockReturnThis(), } as any; -const measures = { count: { name: 'count', label: 'Count', type: 'count' as const, sql: '*' } }; -const dimensions = { status: { name: 'status', label: 'Status', type: 'string' as const, sql: 'status' } }; +const measures = { count: { label: 'Count', type: 'count' as const, sql: '*' } }; +const dimensions = { status: { label: 'Status', type: 'string' as const, sql: 'status' } }; const hiddenCube: Cube = { name: 'hidden_cube', sql: 'hidden_table', measures, dimensions, public: false }; const visibleCube: Cube = { name: 'visible_cube', sql: 'visible_table', measures, dimensions, public: true }; diff --git a/packages/services/service-analytics/src/__tests__/dataset-refusal-envelope.test.ts b/packages/services/service-analytics/src/__tests__/dataset-refusal-envelope.test.ts index 6a0f8d0d6fb..2c860d3125d 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-refusal-envelope.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-refusal-envelope.test.ts @@ -111,8 +111,8 @@ const bareCube: Cube = { name: 'pipeline', title: 'Pipeline', sql: 'crm_opportunity', - measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, - dimensions: { stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' } }, + measures: { revenue: { label: 'Revenue', type: 'sum', sql: 'amount' } }, + dimensions: { stage: { label: 'Stage', type: 'string', sql: 'stage' } }, public: true, }; diff --git a/packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts b/packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts index 54b3ac2a0cc..502a61ef34e 100644 --- a/packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts +++ b/packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts @@ -140,10 +140,10 @@ async function nativeSql(range: readonly unknown[]): Promise<{ where: string; pa const PREVIEW_CUBE = { name: 'events', sql: 'events', dimensions: { - id: { name: 'id', type: 'string', sql: 'id' }, - created_at: { name: 'created_at', type: 'time', sql: 'created_at' }, + id: { type: 'string', sql: 'id' }, + created_at: { type: 'time', sql: 'created_at' }, }, - measures: { count: { name: 'count', type: 'count', sql: '*' } }, + measures: { count: { type: 'count', sql: '*' } }, } as unknown as Cube; /** diff --git a/packages/services/service-analytics/src/__tests__/dimension-source-field-gate.test.ts b/packages/services/service-analytics/src/__tests__/dimension-source-field-gate.test.ts index fd0516c99d4..698831301d4 100644 --- a/packages/services/service-analytics/src/__tests__/dimension-source-field-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/dimension-source-field-gate.test.ts @@ -276,10 +276,10 @@ describe('#5520 — the gate: a dimension over a missing field is a 400, not a d name: 'account_cube', title: 'Accounts', sql: 'crm_account', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: { - industry: { name: 'industry', label: 'Industry', type: 'string', sql: 'industry' }, - legacy: { name: 'legacy', label: 'Legacy', type: 'string', sql: 'dropped_column' }, + industry: { label: 'Industry', type: 'string', sql: 'industry' }, + legacy: { label: 'Legacy', type: 'string', sql: 'dropped_column' }, }, public: true, }; @@ -346,9 +346,9 @@ describe('#5520 — the dataset face: refused before SQL exists, so nothing can name: 'derived_cube', title: 'Derived', sql: 'SELECT * FROM crm_account', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: { - bogus_dim: { name: 'bogus_dim', label: 'x', type: 'string', sql: 'bogus_dim' }, + bogus_dim: { label: 'x', type: 'string', sql: 'bogus_dim' }, }, public: true, }; @@ -421,9 +421,9 @@ describe('#5520 — what the gate must NOT do', () => { name: 'renamed_cube', title: 'Renamed', sql: 'crm_account', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: { - assessed: { name: 'assessed', label: 'Assessed', type: 'time', sql: 'assessed_at' }, + assessed: { label: 'Assessed', type: 'time', sql: 'assessed_at' }, }, public: true, }; @@ -453,9 +453,9 @@ describe('#5520 — what the gate must NOT do', () => { name: 'derived_cube', title: 'Derived', sql: 'SELECT * FROM crm_account WHERE active = 1', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: { - anything: { name: 'anything', label: 'x', type: 'string', sql: 'anything' }, + anything: { label: 'x', type: 'string', sql: 'anything' }, }, public: true, }; @@ -476,7 +476,7 @@ describe('#5520 — what the gate must NOT do', () => { name: 'joined_cube', title: 'Joined', sql: 'crm_account', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, public: true, }; @@ -495,10 +495,9 @@ describe('#5520 — what the gate must NOT do', () => { name: 'computed_cube', title: 'Computed', sql: 'crm_account', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: { bucket: { - name: 'bucket', label: 'Bucket', type: 'string', sql: "CASE WHEN annual_revenue > 0 THEN 'yes' ELSE 'no' END", @@ -532,9 +531,9 @@ describe('#5520 — what the gate must NOT do', () => { name: 'external_cube', title: 'External', sql: 'remote_table', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: { - ghost: { name: 'ghost', label: 'x', type: 'string', sql: 'ghost' }, + ghost: { label: 'x', type: 'string', sql: 'ghost' }, }, public: true, }; diff --git a/packages/services/service-analytics/src/__tests__/dotted-measure-refusal.test.ts b/packages/services/service-analytics/src/__tests__/dotted-measure-refusal.test.ts index 649e3ea89ef..c4d37e47d20 100644 --- a/packages/services/service-analytics/src/__tests__/dotted-measure-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/dotted-measure-refusal.test.ts @@ -355,7 +355,7 @@ describe('[#5918] the warm registry gets the same answer as the cold one', () => name: 'crm_account', title: 'Accounts', sql: 'crm_account', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, public: true, }; @@ -413,9 +413,9 @@ describe('[#5918] the surfaces the ruling leaves alone', () => { title: 'Accounts', sql: 'crm_account', measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, + count: { label: 'Count', type: 'count', sql: '*' }, 'owner.amount_sum': { - name: 'owner.amount_sum', label: 'Owner amount', type: 'sum', sql: 'owner.amount', + label: 'Owner amount', type: 'sum', sql: 'owner.amount', }, }, dimensions: {}, diff --git a/packages/services/service-analytics/src/__tests__/filter-array-lowering.test.ts b/packages/services/service-analytics/src/__tests__/filter-array-lowering.test.ts index 02aed489cf2..418a75a87f1 100644 --- a/packages/services/service-analytics/src/__tests__/filter-array-lowering.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-array-lowering.test.ts @@ -65,11 +65,11 @@ const CUBE: Cube = { name: 'deals', title: 'Deals', sql: 'deal', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: Object.fromEntries( ['id', 'stage', 'owner', 'amount', 'closed_at'].map((n) => [ n, - { name: n, label: n, type: n === 'amount' ? 'number' : 'string', sql: n }, + { label: n, type: n === 'amount' ? 'number' : 'string', sql: n }, ]), ), public: true, diff --git a/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts b/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts index d47237422b2..d9321c9d09b 100644 --- a/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts @@ -94,11 +94,11 @@ const CUBE: Cube = { name: 'deals', title: 'Deals', sql: 'deal', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: Object.fromEntries( ['id', 'stage', 'owner', 'amount'].map((n) => [ n, - { name: n, label: n, type: n === 'amount' ? 'number' : 'string', sql: n }, + { label: n, type: n === 'amount' ? 'number' : 'string', sql: n }, ]), ), public: true, diff --git a/packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts b/packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts index 2e1d4f24909..a1be118fb20 100644 --- a/packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-operator-coverage.test.ts @@ -48,11 +48,11 @@ const CUBE: Cube = { name: 'ops', title: 'Ops', sql: 'ops', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, - score: { name: 'score', label: 'Score', type: 'number', sql: 'score' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + name: { label: 'Name', type: 'string', sql: 'name' }, + score: { label: 'Score', type: 'number', sql: 'score' }, }, public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts b/packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts index 99c81736282..159881c4b21 100644 --- a/packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts +++ b/packages/services/service-analytics/src/__tests__/filter-value-type-fidelity.test.ts @@ -294,11 +294,11 @@ const CUBE: Cube = { name: 'orders', title: 'Orders', sql: 'orders', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - code: { name: 'code', label: 'Code', type: 'string', sql: 'code' }, - score: { name: 'score', label: 'Score', type: 'number', sql: 'score' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + code: { label: 'Code', type: 'string', sql: 'code' }, + score: { label: 'Score', type: 'number', sql: 'score' }, }, public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts b/packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts index da4c34ab668..6c52d5d9ddd 100644 --- a/packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts +++ b/packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts @@ -99,10 +99,10 @@ const CUBE: Cube = { name: 'texts', title: 'Texts', sql: 'rows', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + name: { label: 'Name', type: 'string', sql: 'name' }, }, public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts b/packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts index 1e1111e8627..47d639ea2b3 100644 --- a/packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts @@ -73,7 +73,7 @@ const CUBE: Cube = { sql: OBJECT, measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( - [['id', 'string'], ['name', 'string'], ['amt', 'number']].map(([n, t]) => [n, { name: n, label: n, type: t, sql: n }]), + [['id', 'string'], ['name', 'string'], ['amt', 'number']].map(([n, t]) => [n, { label: n, type: t, sql: n }]), ), public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/like-metacharacter-escape.test.ts b/packages/services/service-analytics/src/__tests__/like-metacharacter-escape.test.ts index efe37af05c9..bbf46707cd5 100644 --- a/packages/services/service-analytics/src/__tests__/like-metacharacter-escape.test.ts +++ b/packages/services/service-analytics/src/__tests__/like-metacharacter-escape.test.ts @@ -112,10 +112,10 @@ const CUBE: Cube = { name: 'people', title: 'People', sql: 'person', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + name: { label: 'Name', type: 'string', sql: 'name' }, }, public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/measure-expression-both-strategies.test.ts b/packages/services/service-analytics/src/__tests__/measure-expression-both-strategies.test.ts index 50581122d7b..972e57f99ef 100644 --- a/packages/services/service-analytics/src/__tests__/measure-expression-both-strategies.test.ts +++ b/packages/services/service-analytics/src/__tests__/measure-expression-both-strategies.test.ts @@ -81,24 +81,24 @@ const CUBE: Cube = { title: 'Orders', sql: 'orders', measures: { - orders_count: { name: 'orders_count', label: 'Count', type: 'count', sql: '*' }, - total: { name: 'total', label: 'Total', type: 'sum', sql: 'amount' }, - avg_amount: { name: 'avg_amount', label: 'Avg', type: 'avg', sql: 'amount' }, - min_amount: { name: 'min_amount', label: 'Min', type: 'min', sql: 'amount' }, - max_amount: { name: 'max_amount', label: 'Max', type: 'max', sql: 'amount' }, - buyers: { name: 'buyers', label: 'Buyers', type: 'count_distinct', sql: 'buyer' }, + orders_count: { label: 'Count', type: 'count', sql: '*' }, + total: { label: 'Total', type: 'sum', sql: 'amount' }, + avg_amount: { label: 'Avg', type: 'avg', sql: 'amount' }, + min_amount: { label: 'Min', type: 'min', sql: 'amount' }, + max_amount: { label: 'Max', type: 'max', sql: 'amount' }, + buyers: { label: 'Buyers', type: 'count_distinct', sql: 'buyer' }, margin: { - name: 'margin', label: 'Margin', type: 'number', + label: 'Margin', type: 'number', sql: 'SUM(revenue) / NULLIF(SUM(cost), 0)', }, top_status: { - name: 'top_status', label: 'Top status', type: 'string', + label: 'Top status', type: 'string', sql: "MAX(CASE WHEN paid THEN 'paid' ELSE 'open' END)", }, - any_paid: { name: 'any_paid', label: 'Any paid', type: 'boolean', sql: 'MAX(paid)' }, + any_paid: { label: 'Any paid', type: 'boolean', sql: 'MAX(paid)' }, }, dimensions: { - status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, + status: { label: 'Status', type: 'string', sql: 'status' }, }, joins: { account: { name: 'crm_account' } }, } as never; @@ -113,8 +113,8 @@ const DRIFT_CUBE: Cube = { name: 'orders_drift', title: 'Orders drift', sql: 'orders', - measures: { weird: { name: 'weird', label: 'Weird', type: 'median', sql: 'amount' } }, - dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } }, + measures: { weird: { label: 'Weird', type: 'median', sql: 'amount' } }, + dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } }, } as never; type Refusal = Error & { diff --git a/packages/services/service-analytics/src/__tests__/measure-expression-sql.test.ts b/packages/services/service-analytics/src/__tests__/measure-expression-sql.test.ts index c8216696ae8..c5430cbe373 100644 --- a/packages/services/service-analytics/src/__tests__/measure-expression-sql.test.ts +++ b/packages/services/service-analytics/src/__tests__/measure-expression-sql.test.ts @@ -28,21 +28,21 @@ const cube: Cube = { title: 'Orders', sql: 'orders', measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, - total: { name: 'total', label: 'Total', type: 'sum', sql: 'amount' }, + count: { label: 'Count', type: 'count', sql: '*' }, + total: { label: 'Total', type: 'sum', sql: 'amount' }, // The three custom-expression types. `sql` IS the computation. margin: { - name: 'margin', label: 'Margin', type: 'number', + label: 'Margin', type: 'number', sql: 'SUM(revenue) / NULLIF(SUM(cost), 0)', }, top_status: { - name: 'top_status', label: 'Top status', type: 'string', + label: 'Top status', type: 'string', sql: "MAX(CASE WHEN paid THEN 'paid' ELSE 'open' END)", }, - any_paid: { name: 'any_paid', label: 'Any paid', type: 'boolean', sql: 'MAX(paid)' }, + any_paid: { label: 'Any paid', type: 'boolean', sql: 'MAX(paid)' }, }, dimensions: { - status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, + status: { label: 'Status', type: 'string', sql: 'status' }, }, } as never; @@ -92,11 +92,11 @@ describe('an expression containing a dot is not mistaken for a join path', () => ...cube.measures, // A dot inside a function call — an expression, not `relation.column`. acct_total: { - name: 'acct_total', label: 'Account total', type: 'number', + label: 'Account total', type: 'number', sql: 'SUM(account.amount) / 2', }, // A genuine relationship path, which MUST still be qualified and joined. - acct_amount: { name: 'acct_amount', label: 'Account amount', type: 'sum', sql: 'account.amount' }, + acct_amount: { label: 'Account amount', type: 'sum', sql: 'account.amount' }, }, } as never; const dottedCtx = { ...(ctx as object), getCube: () => dotted } as never; @@ -131,7 +131,7 @@ describe('the questions COUNT(*) used to answer now fail loudly', () => { it('throws for an unrecognised metric type', async () => { const bad = { ...cube, - measures: { weird: { name: 'weird', label: 'Weird', type: 'median', sql: 'amount' } }, + measures: { weird: { label: 'Weird', type: 'median', sql: 'amount' } }, } as never; const badCtx = { ...(ctx as object), getCube: () => bad } as never; await expect(new NativeSQLStrategy().generateSql({ cube: 'orders', measures: ['weird'] }, badCtx)) @@ -141,7 +141,7 @@ describe('the questions COUNT(*) used to answer now fail loudly', () => { it('the unrecognised-type error lists both vocabularies', async () => { const bad = { ...cube, - measures: { weird: { name: 'weird', label: 'Weird', type: 'median', sql: 'amount' } }, + measures: { weird: { label: 'Weird', type: 'median', sql: 'amount' } }, } as never; const badCtx = { ...(ctx as object), getCube: () => bad } as never; const err = await new NativeSQLStrategy() diff --git a/packages/services/service-analytics/src/__tests__/measure-field-and-filter-compilation.test.ts b/packages/services/service-analytics/src/__tests__/measure-field-and-filter-compilation.test.ts index 18615fd21b6..0fddb1f61c9 100644 --- a/packages/services/service-analytics/src/__tests__/measure-field-and-filter-compilation.test.ts +++ b/packages/services/service-analytics/src/__tests__/measure-field-and-filter-compilation.test.ts @@ -232,8 +232,8 @@ describe('[#10298] `/api/v1/analytics/query` compiles every per-measure `filter` cubes: [{ name: 'crm_case', title: 'Cases', sql: 'crm_case', public: true, measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, - amount_sum: { name: 'amount_sum', label: 'Amount', type: 'sum', sql: 'amount' }, + count: { label: 'Count', type: 'count', sql: '*' }, + amount_sum: { label: 'Amount', type: 'sum', sql: 'amount' }, }, dimensions: {}, }], diff --git a/packages/services/service-analytics/src/__tests__/measure-source-field-gate.test.ts b/packages/services/service-analytics/src/__tests__/measure-source-field-gate.test.ts index 97a33ea7794..a4c1d98280f 100644 --- a/packages/services/service-analytics/src/__tests__/measure-source-field-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/measure-source-field-gate.test.ts @@ -226,9 +226,9 @@ describe('#4437 — measure source-field gate', () => { title: 'Invoices', sql: 'showcase_invoice', measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, - revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'total' }, - legacy: { name: 'legacy', label: 'Legacy', type: 'sum', sql: 'dropped_column' }, + count: { label: 'Count', type: 'count', sql: '*' }, + revenue: { label: 'Revenue', type: 'sum', sql: 'total' }, + legacy: { label: 'Legacy', type: 'sum', sql: 'dropped_column' }, }, dimensions: {}, public: true, @@ -252,7 +252,7 @@ describe('#4437 — measure source-field gate', () => { name: 'derived_cube', title: 'Derived', sql: 'SELECT * FROM showcase_invoice WHERE status = 1', - measures: { anything_sum: { name: 'anything_sum', label: 'x', type: 'sum', sql: 'anything' } }, + measures: { anything_sum: { label: 'x', type: 'sum', sql: 'anything' } }, dimensions: {}, public: true, }; @@ -282,7 +282,7 @@ describe('#4437 — measure source-field gate', () => { title: 'Joined', sql: 'showcase_invoice', measures: { - remote_sum: { name: 'remote_sum', label: 'Remote', type: 'sum', sql: 'account.balance' }, + remote_sum: { label: 'Remote', type: 'sum', sql: 'account.balance' }, }, dimensions: {}, public: true, @@ -325,7 +325,7 @@ describe('#4437 — measure source-field gate', () => { name: 'external_cube', title: 'External', sql: 'remote_table', - measures: { ghost_sum: { name: 'ghost_sum', label: 'x', type: 'sum', sql: 'ghost' } }, + measures: { ghost_sum: { label: 'x', type: 'sum', sql: 'ghost' } }, dimensions: {}, public: true, }; diff --git a/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter-column.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter-column.test.ts index 28147b10c08..74c8130e6be 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter-column.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter-column.test.ts @@ -34,12 +34,12 @@ const cube: Cube = { name: 'compliance', title: 'Compliance', sql: 'compliance_assessment', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { // Dimension id deliberately differs from the column, so a hook that fires on // `assessed_at` proves the storage target resolved the real column. - assessed: { name: 'assessed', label: 'Assessed', type: 'time', sql: 'assessed_at' }, - title: { name: 'title', label: 'Title', type: 'string', sql: 'title' }, + assessed: { label: 'Assessed', type: 'time', sql: 'assessed_at' }, + title: { label: 'Title', type: 'string', sql: 'title' }, }, public: true, }; diff --git a/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter.test.ts index 2df254b6bef..f66a11d02e5 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-datetime-filter.test.ts @@ -48,12 +48,12 @@ const cube: Cube = { name: 'compliance', title: 'Compliance', sql: 'compliance_assessment', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { // NB: dimension id `assessed` deliberately differs from column `assessed_at` // to prove the storage target resolves the real column, not the member name. - assessed: { name: 'assessed', label: 'Assessed', type: 'time', sql: 'assessed_at' }, - score: { name: 'score', label: 'Score', type: 'number', sql: 'score' }, + assessed: { label: 'Assessed', type: 'time', sql: 'assessed_at' }, + score: { label: 'Score', type: 'number', sql: 'score' }, }, public: true, }; diff --git a/packages/services/service-analytics/src/__tests__/native-sql-filter-logic-conformance.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-filter-logic-conformance.test.ts index 65372082cb0..d98bbe350ed 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-filter-logic-conformance.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-filter-logic-conformance.test.ts @@ -50,11 +50,11 @@ const CUBE: Cube = { name: 'logic', title: 'Logic', sql: 't', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: Object.fromEntries( ['id', 'a', 'b', 'c', 'd', 'owner', 'status', 'parent_object', 'parent_id'].map((n) => [ n, - { name: n, label: n, type: 'string', sql: n }, + { label: n, type: 'string', sql: n }, ]), ), public: true, diff --git a/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts index 379d042f59e..83fdfeb44b6 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts @@ -10,8 +10,8 @@ const cube: Cube = { name: 'sales', title: 'Sales', sql: 'opportunity', - measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, - dimensions: { region: { name: 'region', label: 'Region', type: 'string', sql: 'account.region' } }, + measures: { revenue: { label: 'Revenue', type: 'sum', sql: 'amount' } }, + dimensions: { region: { label: 'Region', type: 'string', sql: 'account.region' } }, public: true, }; @@ -118,10 +118,10 @@ describe('NativeSQLStrategy — base-column qualification under joins', () => { name: 'sales', title: 'Sales', sql: 'opportunity', - measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, + measures: { revenue: { label: 'Revenue', type: 'sum', sql: 'amount' } }, dimensions: { - status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, - region: { name: 'region', label: 'Region', type: 'string', sql: 'account.region' }, + status: { label: 'Status', type: 'string', sql: 'status' }, + region: { label: 'Region', type: 'string', sql: 'account.region' }, }, joins: { account: { name: 'account' } }, public: true, @@ -144,8 +144,8 @@ describe('NativeSQLStrategy — base-column qualification under joins', () => { it('leaves base columns BARE for a single-object cube (no joins) — generated SQL unchanged', async () => { const soloCube: Cube = { name: 'tasks', title: 'Tasks', sql: 'task', - measures: { c: { name: 'c', label: 'Count', type: 'count', sql: '*' } }, - dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } }, + measures: { c: { label: 'Count', type: 'count', sql: '*' } }, + dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } }, public: true, }; const strategy = new NativeSQLStrategy(); @@ -163,9 +163,9 @@ describe('NativeSQLStrategy — multi-hop joins (ADR-0071)', () => { name: 'sales', title: 'Sales', sql: 'opportunity', - measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, + measures: { revenue: { label: 'Revenue', type: 'sum', sql: 'amount' } }, dimensions: { - owner_region: { name: 'owner_region', label: 'Owner Region', type: 'string', sql: 'account.owner.region' }, + owner_region: { label: 'Owner Region', type: 'string', sql: 'account.owner.region' }, }, joins: { account: { name: 'crm_account' }, diff --git a/packages/services/service-analytics/src/__tests__/native-sql-temporal-conformance.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-temporal-conformance.test.ts index 6424bed85e8..078f9cc0dd0 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-temporal-conformance.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-temporal-conformance.test.ts @@ -64,11 +64,11 @@ const CUBE: Cube = { name: 'conformance', title: 'Conformance', sql: 'conformance', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - at: { name: 'at', label: 'At', type: 'time', sql: 'happened_at' }, - on: { name: 'on', label: 'On', type: 'time', sql: 'happened_on' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + at: { label: 'At', type: 'time', sql: 'happened_at' }, + on: { label: 'On', type: 'time', sql: 'happened_on' }, }, public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/objectql-contains-canonical-operator.test.ts b/packages/services/service-analytics/src/__tests__/objectql-contains-canonical-operator.test.ts index 1bcfe5279cb..dcb1a443b6c 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-contains-canonical-operator.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-contains-canonical-operator.test.ts @@ -81,10 +81,10 @@ const CUBE: Cube = { name: 'deals', title: 'Deals', sql: 'deal', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + stage: { label: 'Stage', type: 'string', sql: 'stage' }, }, public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/objectql-dataset-filter.test.ts b/packages/services/service-analytics/src/__tests__/objectql-dataset-filter.test.ts index 8a265835f24..581f9d5588e 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-dataset-filter.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-dataset-filter.test.ts @@ -430,7 +430,7 @@ describe('[#10413] what the dataset filter must and must not do', () => { }, cubes: [{ name: 'crm_opportunity', title: 'Opportunities', sql: 'crm_opportunity', public: true, - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, }], }); diff --git a/packages/services/service-analytics/src/__tests__/objectql-echo-operator-coverage.test.ts b/packages/services/service-analytics/src/__tests__/objectql-echo-operator-coverage.test.ts index 05e760c9161..40d2df866be 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-echo-operator-coverage.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-echo-operator-coverage.test.ts @@ -72,11 +72,11 @@ const CUBE: Cube = { name: 'deals', title: 'Deals', sql: 'deal', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: Object.fromEntries( ['id', 'stage', 'amount'].map((n) => [ n, - { name: n, label: n, type: n === 'amount' ? 'number' : 'string', sql: n }, + { label: n, type: n === 'amount' ? 'number' : 'string', sql: n }, ]), ), public: true, diff --git a/packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts b/packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts index 047ac5bb0f6..d098b06105f 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts @@ -71,7 +71,7 @@ const CUBE: Cube = { sql: OBJECT, measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( - [['id', 'string'], ['name', 'string'], ['amt', 'number']].map(([n, t]) => [n, { name: n, label: n, type: t, sql: n }]), + [['id', 'string'], ['name', 'string'], ['amt', 'number']].map(([n, t]) => [n, { label: n, type: t, sql: n }]), ), public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/objectql-read-scope-vacancy-refusal.test.ts b/packages/services/service-analytics/src/__tests__/objectql-read-scope-vacancy-refusal.test.ts index 541a2615dfa..e6695ff1c2b 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-read-scope-vacancy-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-read-scope-vacancy-refusal.test.ts @@ -99,7 +99,7 @@ const CUBE: Cube = { sql: OBJECT, measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( - ['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), + ['id', 'owner'].map((n) => [n, { label: n, type: 'string', sql: n }]), ), public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/preview-aggregate-operand-type.test.ts b/packages/services/service-analytics/src/__tests__/preview-aggregate-operand-type.test.ts index 907cfd0db9a..1261b0d70c6 100644 --- a/packages/services/service-analytics/src/__tests__/preview-aggregate-operand-type.test.ts +++ b/packages/services/service-analytics/src/__tests__/preview-aggregate-operand-type.test.ts @@ -215,8 +215,8 @@ describe('#16203 (a) — a `min`/`max` over a non-numeric field is a VALUE of th it('a group whose operand is null throughout answers null, never 0 (`emptyGroupValueFor`)', () => { const CUBE = { name: 'e', sql: 'expense', - dimensions: { category: { name: 'category', type: 'string', sql: 'category' } }, - measures: { latest: { name: 'latest', type: 'max', sql: 'spent_on' } }, + dimensions: { category: { type: 'string', sql: 'category' } }, + measures: { latest: { type: 'max', sql: 'spent_on' } }, } as unknown as Cube; const r = evaluateAnalyticsQueryOverRows( { measures: ['latest'], dimensions: ['category'] }, @@ -233,7 +233,7 @@ describe('#16203 (a) — a `min`/`max` over a non-numeric field is a VALUE of th const CUBE = { name: 'e', sql: 'expense', dimensions: {}, - measures: { latest: { name: 'latest', type: 'max', sql: 'at' } }, + measures: { latest: { type: 'max', sql: 'at' } }, } as unknown as Cube; const early = new Date('2026-05-03T00:00:00.000Z'); const late = new Date('2026-07-27T00:00:00.000Z'); diff --git a/packages/services/service-analytics/src/__tests__/preview-avg-empty-group.test.ts b/packages/services/service-analytics/src/__tests__/preview-avg-empty-group.test.ts index fc0590b0390..a67924f2edc 100644 --- a/packages/services/service-analytics/src/__tests__/preview-avg-empty-group.test.ts +++ b/packages/services/service-analytics/src/__tests__/preview-avg-empty-group.test.ts @@ -297,9 +297,9 @@ describe('#16219 — the empty-operand answer is READ from the policy, not resta name: 'e', sql: 'expense', dimensions: {}, measures: { - avg_amount: { name: 'avg_amount', type: 'avg', sql: 'amount' }, - sum_amount: { name: 'sum_amount', type: 'sum', sql: 'amount' }, - rows: { name: 'rows', type: 'count', sql: '*' }, + avg_amount: { type: 'avg', sql: 'amount' }, + sum_amount: { type: 'sum', sql: 'amount' }, + rows: { type: 'count', sql: '*' }, }, } as unknown as Cube; // This is the branch the card named — reachable only where NO row carries a @@ -316,8 +316,8 @@ describe('#16219 — the empty-operand answer is READ from the policy, not resta it('a field ABSENT from every row averages null, exactly like a null-valued one', () => { const CUBE = { name: 'e', sql: 'expense', - dimensions: { category: { name: 'category', type: 'string', sql: 'category' } }, - measures: { avg_amount: { name: 'avg_amount', type: 'avg', sql: 'amount' } }, + dimensions: { category: { type: 'string', sql: 'category' } }, + measures: { avg_amount: { type: 'avg', sql: 'amount' } }, } as unknown as Cube; // `undefined` (key never written) and `null` are one population to `AVG`: // neither is a value. Before the fix these two rows answered 0 by different @@ -333,8 +333,8 @@ describe('#16219 — the empty-operand answer is READ from the policy, not resta it('⛔ values PRESENT but not numbers stay `0` — that is #16099, not this card', () => { const CUBE = { name: 'e', sql: 'expense', - dimensions: { category: { name: 'category', type: 'string', sql: 'category' } }, - measures: { avg_spent_on: { name: 'avg_spent_on', type: 'avg', sql: 'spent_on' } }, + dimensions: { category: { type: 'string', sql: 'category' } }, + measures: { avg_spent_on: { type: 'avg', sql: 'spent_on' } }, } as unknown as Cube; // The boundary this fix is drawn on. "No numeric operand" is two different // situations and only one of them is averaging NOTHING: a group whose rows @@ -355,8 +355,8 @@ describe('#16219 — the empty-operand answer is READ from the policy, not resta it('a single numeric operand still averages to itself', () => { const CUBE = { name: 'e', sql: 'expense', - dimensions: { category: { name: 'category', type: 'string', sql: 'category' } }, - measures: { avg_amount: { name: 'avg_amount', type: 'avg', sql: 'amount' } }, + dimensions: { category: { type: 'string', sql: 'category' } }, + measures: { avg_amount: { type: 'avg', sql: 'amount' } }, } as unknown as Cube; const r = evaluateAnalyticsQueryOverRows( { measures: ['avg_amount'], dimensions: ['category'] }, diff --git a/packages/services/service-analytics/src/__tests__/preview-count-over-field.test.ts b/packages/services/service-analytics/src/__tests__/preview-count-over-field.test.ts index 63f95fa38b4..60d4e25f933 100644 --- a/packages/services/service-analytics/src/__tests__/preview-count-over-field.test.ts +++ b/packages/services/service-analytics/src/__tests__/preview-count-over-field.test.ts @@ -277,8 +277,8 @@ describe('#16218 — a group where NO row carries a value answers 0, never null' name: 'e', sql: 'expense', dimensions: {}, measures: { - rows: { name: 'rows', type: 'count', sql: '*' }, - payers: { name: 'payers', type: 'count', sql: 'payer' }, + rows: { type: 'count', sql: '*' }, + payers: { type: 'count', sql: 'payer' }, }, } as unknown as Cube; const r = evaluateAnalyticsQueryOverRows({ measures: ['rows', 'payers'], dimensions: [] }, CUBE, []); @@ -288,8 +288,8 @@ describe('#16218 — a group where NO row carries a value answers 0, never null' it('a field ABSENT from every row counts 0, exactly like a null-valued one', () => { const CUBE = { name: 'e', sql: 'expense', - dimensions: { category: { name: 'category', type: 'string', sql: 'category' } }, - measures: { payers: { name: 'payers', type: 'count', sql: 'payer' } }, + dimensions: { category: { type: 'string', sql: 'category' } }, + measures: { payers: { type: 'count', sql: 'payer' } }, } as unknown as Cube; // `undefined` (key never written) and `null` are one population to // `COUNT(col)`: neither is a value. diff --git a/packages/services/service-analytics/src/__tests__/preview-evaluator.test.ts b/packages/services/service-analytics/src/__tests__/preview-evaluator.test.ts index a18ff6e8451..7cbf9e3db20 100644 --- a/packages/services/service-analytics/src/__tests__/preview-evaluator.test.ts +++ b/packages/services/service-analytics/src/__tests__/preview-evaluator.test.ts @@ -91,12 +91,12 @@ describe('evaluateAnalyticsQueryOverRows', () => { name: 'expense_ds', sql: 'expense', dimensions: { - category: { name: 'category', type: 'string', sql: 'category' }, - spent_on: { name: 'spent_on', type: 'time', sql: 'spent_on', granularities: ['month'] }, + category: { type: 'string', sql: 'category' }, + spent_on: { type: 'time', sql: 'spent_on', granularities: ['month'] }, }, measures: { - count: { name: 'count', type: 'count', sql: '*' }, - total_amount: { name: 'total_amount', type: 'sum', sql: 'amount' }, + count: { type: 'count', sql: '*' }, + total_amount: { type: 'sum', sql: 'amount' }, }, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/preview-temporal-conformance.test.ts b/packages/services/service-analytics/src/__tests__/preview-temporal-conformance.test.ts index 9b1660eeac7..ec3be03bc1d 100644 --- a/packages/services/service-analytics/src/__tests__/preview-temporal-conformance.test.ts +++ b/packages/services/service-analytics/src/__tests__/preview-temporal-conformance.test.ts @@ -87,8 +87,8 @@ describe('preview-evaluator — timeDimensions.dateRange temporal conformance', const CUBE = { name: 'conformance_ds', sql: 'conformance', - dimensions: { id: { name: 'id', type: 'string', sql: 'id' } }, - measures: { count: { name: 'count', type: 'count', sql: '*' } }, + dimensions: { id: { type: 'string', sql: 'id' } }, + measures: { count: { type: 'count', sql: '*' } }, } as unknown as Cube; for (const c of TEMPORAL_CASES) { diff --git a/packages/services/service-analytics/src/__tests__/query-dataset-request-scope.test.ts b/packages/services/service-analytics/src/__tests__/query-dataset-request-scope.test.ts index 8f4041e5503..c2d9d991de3 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset-request-scope.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset-request-scope.test.ts @@ -51,10 +51,10 @@ const OPEN_SUMMARY: Cube = { title: 'Open summary', sql: 'open_obj', measures: { - authored_total: { name: 'authored_total', label: 'Authored total', type: 'count', sql: '*' }, + authored_total: { label: 'Authored total', type: 'count', sql: '*' }, }, dimensions: { - region: { name: 'region', label: 'Region', type: 'string', sql: 'region' }, + region: { label: 'Region', type: 'string', sql: 'region' }, }, }; @@ -63,10 +63,10 @@ const HIDDEN_SUMMARY: Cube = { title: 'Hidden summary', sql: 'open_obj', measures: { - hidden_total: { name: 'hidden_total', label: 'Hidden total', type: 'count', sql: '*' }, + hidden_total: { label: 'Hidden total', type: 'count', sql: '*' }, }, dimensions: { - region: { name: 'region', label: 'Region', type: 'string', sql: 'region' }, + region: { label: 'Region', type: 'string', sql: 'region' }, }, public: false, }; diff --git a/packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts index e8b0c857eae..6307e684af5 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts @@ -95,7 +95,7 @@ const CUBE: Cube = { sql: OBJECT, measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( - ['id', 'region'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), + ['id', 'region'].map((n) => [n, { label: n, type: 'string', sql: n }]), ), public: true, } as unknown as Cube; @@ -397,8 +397,8 @@ describe('[#20018] `applyReadScope` judges the JOINED object\'s scope too', () = name: 'sales', title: 'Sales', sql: 'opportunity', - measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, - dimensions: { region: { name: 'region', label: 'Region', type: 'string', sql: 'account.region' } }, + measures: { revenue: { label: 'Revenue', type: 'sum', sql: 'amount' } }, + dimensions: { region: { label: 'Region', type: 'string', sql: 'account.region' } }, public: true, }; diff --git a/packages/services/service-analytics/src/__tests__/read-scope-empty-nin-refusal.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-empty-nin-refusal.test.ts index db635b7e3d7..57eb9aa9fab 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-empty-nin-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-empty-nin-refusal.test.ts @@ -57,9 +57,9 @@ const CUBE: Cube = { name: 'deals', title: 'Deals', sql: 'deal', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: Object.fromEntries( - ['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), + ['id', 'owner'].map((n) => [n, { label: n, type: 'string', sql: n }]), ), public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/read-scope-eq-array-refusal.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-eq-array-refusal.test.ts index 11bc7b919fb..77b5cb7ad7c 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-eq-array-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-eq-array-refusal.test.ts @@ -152,7 +152,7 @@ const CUBE: Cube = { sql: OBJECT, measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( - ['id', 'status'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), + ['id', 'status'].map((n) => [n, { label: n, type: 'string', sql: n }]), ), public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts index 5b045baa869..258e2af79d3 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts @@ -91,8 +91,8 @@ const cube: Cube = { name: 'deals', title: 'Deals', sql: 'deal', - measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, - dimensions: { stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' } }, + measures: { revenue: { label: 'Revenue', type: 'sum', sql: 'amount' } }, + dimensions: { stage: { label: 'Stage', type: 'string', sql: 'stage' } }, public: true, }; diff --git a/packages/services/service-analytics/src/__tests__/read-scope-vacancy-three-faces.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-vacancy-three-faces.test.ts index 5e77d843e7e..492bfaabfa0 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-vacancy-three-faces.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-vacancy-three-faces.test.ts @@ -96,7 +96,7 @@ const CUBE: Cube = { sql: OBJECT, measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( - ['id', 'owner'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), + ['id', 'owner'].map((n) => [n, { label: n, type: 'string', sql: n }]), ), public: true, } as unknown as Cube; @@ -343,8 +343,8 @@ describe('[#13926] `applyReadScope` guards the JOINED object\'s scope too', () = name: 'sales', title: 'Sales', sql: 'opportunity', - measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, - dimensions: { region: { name: 'region', label: 'Region', type: 'string', sql: 'account.region' } }, + measures: { revenue: { label: 'Revenue', type: 'sum', sql: 'amount' } }, + dimensions: { region: { label: 'Region', type: 'string', sql: 'account.region' } }, public: true, }; diff --git a/packages/services/service-analytics/src/__tests__/sql-dialect-vocabulary.test.ts b/packages/services/service-analytics/src/__tests__/sql-dialect-vocabulary.test.ts index 9d31e7d327a..70a80e0786b 100644 --- a/packages/services/service-analytics/src/__tests__/sql-dialect-vocabulary.test.ts +++ b/packages/services/service-analytics/src/__tests__/sql-dialect-vocabulary.test.ts @@ -90,10 +90,10 @@ const CUBE: Cube = { name: 'texts', title: 'Texts', sql: 'rows', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + name: { label: 'Name', type: 'string', sql: 'name' }, }, public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/sql-echo-debug-gate.test.ts b/packages/services/service-analytics/src/__tests__/sql-echo-debug-gate.test.ts index df59f81cfc4..db208cbc9b4 100644 --- a/packages/services/service-analytics/src/__tests__/sql-echo-debug-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/sql-echo-debug-gate.test.ts @@ -57,10 +57,10 @@ const usersCube: Cube = { title: 'Users', sql: 'sys_user', measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, + count: { label: 'Count', type: 'count', sql: '*' }, }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, + id: { label: 'Id', type: 'string', sql: 'id' }, }, public: true, }; diff --git a/packages/services/service-analytics/src/__tests__/text-match-sqlite-nul.test.ts b/packages/services/service-analytics/src/__tests__/text-match-sqlite-nul.test.ts index b6590e5b1ab..45554823a81 100644 --- a/packages/services/service-analytics/src/__tests__/text-match-sqlite-nul.test.ts +++ b/packages/services/service-analytics/src/__tests__/text-match-sqlite-nul.test.ts @@ -162,10 +162,10 @@ const CUBE: Cube = { name: TABLE, title: 'Texts', sql: TABLE, - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { - label: { name: 'label', label: 'Label', type: 'string', sql: 'label' }, - v: { name: 'v', label: 'V', type: 'string', sql: 'v' }, + label: { label: 'Label', type: 'string', sql: 'label' }, + v: { label: 'V', type: 'string', sql: 'v' }, }, public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/text-operator-case-exactness.test.ts b/packages/services/service-analytics/src/__tests__/text-operator-case-exactness.test.ts index 8e915e8b126..8060e65067a 100644 --- a/packages/services/service-analytics/src/__tests__/text-operator-case-exactness.test.ts +++ b/packages/services/service-analytics/src/__tests__/text-operator-case-exactness.test.ts @@ -173,10 +173,10 @@ const CUBE: Cube = { name: 'texts', title: 'Texts', sql: 'rows', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + name: { label: 'Name', type: 'string', sql: 'name' }, }, public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/text-operator-non-text-column.test.ts b/packages/services/service-analytics/src/__tests__/text-operator-non-text-column.test.ts index 5647ac47396..36fc80eab13 100644 --- a/packages/services/service-analytics/src/__tests__/text-operator-non-text-column.test.ts +++ b/packages/services/service-analytics/src/__tests__/text-operator-non-text-column.test.ts @@ -68,11 +68,11 @@ const CUBE: Cube = { name: 'texts', title: 'Texts', sql: 'rows', - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: { - id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, - name: { name: 'name', label: 'Name', type: 'string', sql: 'name' }, - score: { name: 'score', label: 'Score', type: 'number', sql: 'score' }, + id: { label: 'Id', type: 'string', sql: 'id' }, + name: { label: 'Name', type: 'string', sql: 'name' }, + score: { label: 'Score', type: 'number', sql: 'score' }, }, public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts b/packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts index 1ac543da6df..fd3b664853b 100644 --- a/packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts +++ b/packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts @@ -87,15 +87,15 @@ const CUBE: Cube = { title: 'TD Delivery Analytics', sql: OBJECT, measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, + count: { label: 'Count', type: 'count', sql: '*' }, total_estimate_hours: { - name: 'total_estimate_hours', label: 'Total Estimated Hours', type: 'sum', sql: 'estimate_hours', + label: 'Total Estimated Hours', type: 'sum', sql: 'estimate_hours', }, }, dimensions: { - status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, + status: { label: 'Status', type: 'string', sql: 'status' }, // `type: 'time'` over a `Field.date` column — the shape the report buckets. - due_date: { name: 'due_date', label: 'Due Date', type: 'time', sql: 'due_date' }, + due_date: { label: 'Due Date', type: 'time', sql: 'due_date' }, }, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts b/packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts index 878765de469..d7042c5624d 100644 --- a/packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts +++ b/packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts @@ -159,14 +159,14 @@ const joinedCube: Cube = { title: 'Sales by account', sql: 'opportunity', measures: { - revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' }, - avg_deal: { name: 'avg_deal', label: 'Avg deal', type: 'avg', sql: 'amount' }, - remote_sum: { name: 'remote_sum', label: 'Remote', type: 'sum', sql: 'account.balance' }, + revenue: { label: 'Revenue', type: 'sum', sql: 'amount' }, + avg_deal: { label: 'Avg deal', type: 'avg', sql: 'amount' }, + remote_sum: { label: 'Remote', type: 'sum', sql: 'account.balance' }, }, dimensions: { - stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, - region: { name: 'region', label: 'Region', type: 'string', sql: 'account.region' }, - opened: { name: 'opened', label: 'Opened', type: 'time', sql: 'account.created_at' }, + stage: { label: 'Stage', type: 'string', sql: 'stage' }, + region: { label: 'Region', type: 'string', sql: 'account.region' }, + opened: { label: 'Opened', type: 'time', sql: 'account.created_at' }, }, joins: { account: { name: 'account' }, @@ -179,8 +179,8 @@ const countOnlyCube: Cube = { name: 'pipeline', title: 'Pipeline', sql: 'crm_opportunity', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, - dimensions: { stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, + dimensions: { stage: { label: 'Stage', type: 'string', sql: 'stage' } }, public: true, }; @@ -538,7 +538,7 @@ describe('[#5716] the verdicts that deliberately stay an undeclared 500', () => // invariant it sits beside. const offSpec = { ...countOnlyCube, - measures: { median_deal: { name: 'median_deal', label: 'Median', type: 'median', sql: 'amount' } }, + measures: { median_deal: { label: 'Median', type: 'median', sql: 'amount' } }, } as unknown as Cube; const err = await refusalFrom(() => diff --git a/packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts index 2d86018753e..67ecb853cf6 100644 --- a/packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts @@ -72,7 +72,7 @@ const CUBE: Cube = { sql: OBJECT, measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( - [['id', 'string'], ['stage', 'string'], ['amt', 'number']].map(([n, t]) => [n, { name: n, label: n, type: t, sql: n }]), + [['id', 'string'], ['stage', 'string'], ['amt', 'number']].map(([n, t]) => [n, { label: n, type: t, sql: n }]), ), public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts b/packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts index 6b1186fa9af..6b6e0ac591b 100644 --- a/packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts @@ -65,9 +65,9 @@ const CUBE: Cube = { name: 'empties', title: 'Empties', sql: TABLE, - measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + measures: { total: { label: 'Total', type: 'count', sql: '*' } }, dimensions: Object.fromEntries( - Object.keys(SHAPES).map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), + Object.keys(SHAPES).map((n) => [n, { label: n, type: 'string', sql: n }]), ), public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts index 9f7068302e4..0222851971d 100644 --- a/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts @@ -237,7 +237,7 @@ const CUBE: Cube = { sql: OBJECT, measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( - ['id', 'stage'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]), + ['id', 'stage'].map((n) => [n, { label: n, type: 'string', sql: n }]), ), public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts index 380f86bb575..874cbf5a798 100644 --- a/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-face-arms-refusal.test.ts @@ -305,7 +305,7 @@ const CUBE: Cube = { sql: OBJECT, measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( - [['id', 'string'], ['amt', 'number'], ['stage', 'string']].map(([n, t]) => [n, { name: n, label: n, type: t, sql: n }]), + [['id', 'string'], ['amt', 'number'], ['stage', 'string']].map(([n, t]) => [n, { label: n, type: t, sql: n }]), ), public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/__tests__/where-source-field-gate.test.ts b/packages/services/service-analytics/src/__tests__/where-source-field-gate.test.ts index 6cf54fbfe99..94c48c60888 100644 --- a/packages/services/service-analytics/src/__tests__/where-source-field-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-source-field-gate.test.ts @@ -336,10 +336,10 @@ describe('#5669 — the gate: a `where` over a missing field is a 400, not a dri name: 'account_cube', title: 'Accounts', sql: 'crm_account', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: { - industry: { name: 'industry', label: 'Industry', type: 'string', sql: 'industry' }, - legacy: { name: 'legacy', label: 'Legacy', type: 'string', sql: 'dropped_column' }, + industry: { label: 'Industry', type: 'string', sql: 'industry' }, + legacy: { label: 'Legacy', type: 'string', sql: 'dropped_column' }, }, public: true, }; @@ -440,7 +440,7 @@ describe('#5669 — the dataset face: refused before SQL exists, so nothing can name: 'derived_cube', title: 'Derived', sql: 'SELECT * FROM crm_account', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, public: true, }; @@ -510,9 +510,9 @@ describe('#5669 — what the gate must NOT do', () => { name: 'renamed_cube', title: 'Renamed', sql: 'crm_account', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: { - assessed: { name: 'assessed', label: 'Assessed', type: 'time', sql: 'assessed_at' }, + assessed: { label: 'Assessed', type: 'time', sql: 'assessed_at' }, }, public: true, }; @@ -538,8 +538,8 @@ describe('#5669 — what the gate must NOT do', () => { title: 'Measures', sql: 'crm_account', measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, - revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'annual_revenue' }, + count: { label: 'Count', type: 'count', sql: '*' }, + revenue: { label: 'Revenue', type: 'sum', sql: 'annual_revenue' }, }, dimensions: {}, public: true, @@ -569,7 +569,7 @@ describe('#5669 — what the gate must NOT do', () => { name: 'derived_cube', title: 'Derived', sql: 'SELECT * FROM crm_account WHERE active = 1', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, public: true, }; @@ -602,7 +602,7 @@ describe('#5669 — what the gate must NOT do', () => { name: 'joined_cube', title: 'Joined', sql: 'crm_account', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, public: true, }; @@ -634,7 +634,7 @@ describe('#5669 — what the gate must NOT do', () => { name: 'joined_cube', title: 'Joined', sql: 'crm_account', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: {}, public: true, }; @@ -698,10 +698,9 @@ describe('#5669 — what the gate must NOT do', () => { name: 'computed_cube', title: 'Computed', sql: 'crm_account', - measures: { count: { name: 'count', label: 'Count', type: 'count', sql: '*' } }, + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, dimensions: { bucket: { - name: 'bucket', label: 'Bucket', type: 'string', sql: "CASE WHEN annual_revenue > 0 THEN 'yes' ELSE 'no' END", diff --git a/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts index 54d83013f69..fb7ac9c9b45 100644 --- a/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-type-face-refusal.test.ts @@ -311,7 +311,7 @@ const CUBE: Cube = { sql: OBJECT, measures: { n: { sql: '*', type: 'count', title: 'n' } }, dimensions: Object.fromEntries( - [['id', 'string'], ['amt', 'number'], ['stage', 'string']].map(([n, t]) => [n, { name: n, label: n, type: t, sql: n }]), + [['id', 'string'], ['amt', 'number'], ['stage', 'string']].map(([n, t]) => [n, { label: n, type: t, sql: n }]), ), public: true, } as unknown as Cube; diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 34361fc6cc3..298b36a1acb 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -2795,8 +2795,10 @@ export class AnalyticsService implements IAnalyticsService { return m.slice(0, dot) === cubeName ? m.slice(dot + 1) : m; }; - // Always provide a default `count` measure - measures.count = { name: 'count', label: 'Count', type: 'count', sql: '*' }; + // Always provide a default `count` measure. Every member minted below is + // filed under its KEY with no inner `name` — the record key IS the member's + // name (#20300 retired the inner copy, ADR-0049 enforce-or-remove). + measures.count = { label: 'Count', type: 'count', sql: '*' }; for (const m of query.measures || []) { // [#5918] MEASURES no longer take the blanket strip #5739 left them with. @@ -2819,7 +2821,7 @@ export class AnalyticsService implements IAnalyticsService { for (const d of query.dimensions || []) { const key = stripCubeQualifier(d); if (dimensions[key]) continue; - dimensions[key] = { name: key, label: key, type: 'string', sql: key }; + dimensions[key] = { label: key, type: 'string', sql: key }; } // The `where`'s field keys seed dimensions too. LOWER FIRST, then read keys: @@ -2867,7 +2869,7 @@ export class AnalyticsService implements IAnalyticsService { // so the cube AND the compiled SQL now match on either spelling. const minted = stripCubeQualifier(key); if (dimensions[minted] || measures[minted]) continue; - dimensions[minted] = { name: minted, label: minted, type: 'string', sql: minted }; + dimensions[minted] = { label: minted, type: 'string', sql: minted }; } } @@ -2875,7 +2877,7 @@ export class AnalyticsService implements IAnalyticsService { const key = stripCubeQualifier(td.dimension); if (dimensions[key]) continue; dimensions[key] = { - name: key, label: key, type: 'time', sql: key, + label: key, type: 'time', sql: key, granularities: ['day', 'week', 'month', 'quarter', 'year'], }; } @@ -3053,9 +3055,11 @@ function mintableMeasureKey(member: string, cubeName: string): string { * Anything else is treated as a `sum()` — best-effort default for an * unknown numeric measure. */ -export function inferMeasure(key: string): { name: string; label: string; type: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'; sql: string } { +export function inferMeasure(key: string): { label: string; type: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'; sql: string } { + // No inner `name`: the caller files the result under `key`, and the record + // key IS the measure's name (#20300 retired the inner copy). if (key === 'count') { - return { name: 'count', label: 'Count', type: 'count', sql: '*' }; + return { label: 'Count', type: 'count', sql: '*' }; } const suffixes: Array<[string, 'sum' | 'avg' | 'min' | 'max' | 'count_distinct']> = [ ['_count_distinct', 'count_distinct'], @@ -3068,10 +3072,10 @@ export function inferMeasure(key: string): { name: string; label: string; type: for (const [suffix, type] of suffixes) { if (key.endsWith(suffix)) { const field = key.slice(0, -suffix.length) || '*'; - return { name: key, label: key, type, sql: field }; + return { label: key, type, sql: field }; } } - return { name: key, label: key, type: 'sum', sql: key }; + return { label: key, type: 'sum', sql: key }; } /** diff --git a/packages/services/service-analytics/src/cube-registry.ts b/packages/services/service-analytics/src/cube-registry.ts index 400b55c9424..82ade36f2a2 100644 --- a/packages/services/service-analytics/src/cube-registry.ts +++ b/packages/services/service-analytics/src/cube-registry.ts @@ -119,9 +119,10 @@ export class CubeRegistry { objectName: string, fields: Array<{ name: string; type: string; label?: string }>, ): Cube { + // Members carry no inner `name`: the record key IS the member's name + // (#20300 retired the inner copy, ADR-0049 enforce-or-remove). const measures: Record = { count: { - name: 'count', label: 'Count', type: 'count', sql: '*', @@ -135,7 +136,6 @@ export class CubeRegistry { // All fields become dimensions const dimType = this.fieldTypeToDimensionType(field.type); dimensions[field.name] = { - name: field.name, label, type: dimType, sql: field.name, @@ -147,13 +147,11 @@ export class CubeRegistry { // Numeric fields also become aggregation measures if (field.type === 'number' || field.type === 'currency' || field.type === 'percent') { measures[`${field.name}_sum`] = { - name: `${field.name}_sum`, label: `${label} (Sum)`, type: 'sum', sql: field.name, }; measures[`${field.name}_avg`] = { - name: `${field.name}_avg`, label: `${label} (Avg)`, type: 'avg', sql: field.name, diff --git a/packages/services/service-analytics/src/dataset-compiler.ts b/packages/services/service-analytics/src/dataset-compiler.ts index ae9fad0d7e2..982c0784e72 100644 --- a/packages/services/service-analytics/src/dataset-compiler.ts +++ b/packages/services/service-analytics/src/dataset-compiler.ts @@ -630,8 +630,11 @@ export function compileDataset( const dimensions: Record = {}; for (const d of dataset.dimensions) { assertDeclared(d.field, 'dimension', d.name); + // The dimension's NAME is the key it is filed under (`dimensions[d.name]` + // below) — the cube member carries no inner copy: #20300 retired + // `Dimension.name` (ADR-0049 enforce-or-remove), because every consumer + // resolves a member by its record key and nothing ever read the inner one. const dim: CubeDimension = { - name: d.name, // [#6761] An inline locale map is a label, not a missing one. Before this, // the `typeof === 'string'` test dropped the map and substituted the // machine name, which `/analytics/meta` then published as a display title @@ -664,8 +667,9 @@ export function compileDataset( // dotted field is refused for the reason it is actually wrong (an // undeclared relationship) before this gate stands down on it. assertAggregateFieldTypeCompatible(dataset.name, dataset.object, m, options?.declaredFieldType); + // Filed under its name (`measures[m.name]` below), with no inner copy — + // the same #20300 retirement as the dimension's. const metric: Metric = { - name: m.name, // [#6761] Same as the dimension label above — see {@link REGISTRY_LOCALE}. label: resolveI18nLabel(m.label, REGISTRY_LOCALE) ?? m.name, type: aggregateToMetricType(m), diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index eb378b14668..ac2aa6b10d7 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -194,7 +194,7 @@ "data/Dimension:description", "data/Dimension:granularities", "data/Dimension:label", - "data/Dimension:name", + "data/Dimension:name [RETIRED]", "data/Dimension:sql", "data/Dimension:type", "data/Document:access", @@ -597,7 +597,7 @@ "data/Metric:description", "data/Metric:format", "data/Metric:label", - "data/Metric:name", + "data/Metric:name [RETIRED]", "data/Metric:sql", "data/Metric:type", "data/MongoConfig:authSource", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 34161962988..fc6cf0c7da3 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -945,7 +945,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | realtime_subscription | seeded 2026-09-04 (#14446) — a TRANSPORT-PROTOCOL surface, the fifth category the `SPEC_ONLY_SCHEMAS` override has had to reach. `SubscriptionSchema` (`packages/spec/src/api/realtime.zod.ts`) is what a client declares to open a realtime subscription: the item type of `RealtimeConfigSchema.subscriptions` and the `Subscription` the generated API reference publishes. Like `query` it is a request surface rather than stored metadata, and like `query` that is exactly why it went unasked — no registry holds it, `RealtimeConfigSchema` is `.passthrough()` so nothing downstream even refuses an unknown key, and the whole vocabulary sat outside the denominator while the reference kept publishing it. Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema` for the reason the four `RestServerConfig` sub-objects document one row up: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with the config as the root `events[].type` and `events[].filters` would inherit a container verdict instead of carrying rows of their own — #4956's shape. **Dead 6 = every key it has, and the CONTAINER is the finding**: nothing outside `packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or `RealtimeConfigSchema` at all, so no key beneath them can be read (the `manifest.contributes` reasoning). The two keys the card measured are the sharp ones. `events[].type` accepts `RealtimeEventType`, whose four members (`record.created` / `record.updated` / `record.deleted` / `field.changed`) are DISJOINT from what the engine publishes (`DataEventType`'s `data.record.*`, live emitter in `service-knowledge`), so an author who writes the enum's own `record.created` gets a subscription that silently never fires — and the enum is what the API reference shows them. Its direction is settled by the 2026-09-02 triage and quoted verbatim in the row: enforce means REPOINTING THE ENUM, never changing what the runtime publishes. `field.changed` is the same spelling the sibling `DataEventType` REMOVED in 17.0.0 (#4673, PR #4685) for having no producer; it survives here only because this enum was never in a ratchet's denominator. `events[].filters` is `z.unknown().optional()` — the textbook ADR-0049 fourth state, no shape and no reader, failing in the permissive direction (a subscriber who filters receives every event). ⚠️ Three spellings of a realtime subscription exist and only the third is executed: this one, `websocket.zod.ts#EventSubscriptionSchema`, and the plain interface `contracts/realtime-service.ts#RealtimeSubscriptionOptions` that `in-memory-realtime-adapter.ts#matchesSubscription` actually reads. The file note names the same-name-different-shape traps so the next census does not mistake one for a consumer. Zero live | | sharing_rule | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, and the first one PAID (`connector` and `analytics_cube` are still owed on that card). Not a registered kind: it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches the walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so this ledger governs a type `listMetadataTypeSchemaTypes()` still does not enumerate. One shape fact decides every row: the AUTHORING shape is not the ENFORCED shape. ADR-0057 D6 makes the `sys_sharing_rule` row canonical (`object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` translates each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time — so every consumer cited reads a COLUMN and every row carries the `producer` (#4837) that populates it, which is the `seed.env` lesson applied to a whole type rather than to one key. Preview read points ENUMERATED per the #7131 rule and the answer recorded rather than skipped: `registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types and `sharing_rule` is not one of them; what objectui does consume is the whole shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES` — the EDIT door is deliberately ungated because a served body carries the `_diagnostics` decoration this `.strict()` schema rejects). The single non-`live` row is `type`, the `SharingRuleType` discriminator: one member, `criteria`, whose only reader is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. `planned` on the `action.operation` precedent (a one-member discriminator held `planned` until a runtime half dispatched on it, #15080), and deliberately NOT an enforce-or-remove candidate: the key is required, so removing it would break every authored rule to delete nothing. | | connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the three rows where they are the whole verdict. **ONE SCHEMA, TWO DOORS** is the shape fact behind the 29/1/30 split (live/planned/dead; counts read from the generated `state-counts/connector.md` shard, never hand-kept here): the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields (the former "plus `name`" tail double-counted it), `loadPackageFile` is host-injected rather than authored, and `provider` selects the factory without ever reaching the context; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`, and ⛔ NOT "refused outright" — the former tail here said exactly that and all three instruments contradict it, including the one it cites: the KEY is ACCEPTED (`connector.zod.ts` declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, and the accepted value does nothing); what #7990 refuses is a non-`none` VALUE (`if (entry.authentication && entry.authentication.type !== 'none')`, whose own message prescribes "drop `authentication` (or set `{ type: 'none' }`)"); and ADR-0097 §3, titled "Credentials are references", rejects **inline secrets** in stack metadata, not the key. Accepted-and-ignored, plus a loud refusal of every value but `{ type: 'none' }`, is exactly the basis of the `planned` verdict — which the row itself already stated ("the accepted value does nothing"), so the summary, not the row, was the wrong half. The 30 `dead`, re-measured at this head and partitioned so every row is counted exactly once: two declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7) — plus `triggers` (6, and the schema's own docblock says so: #3197), `metadata`, `actions.description`/`.outputSchema`, and the six top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status` and `webhooks`. That sums to 30, the dead count the generated `state-counts/connector.md` shard carries. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ EIGHT rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `fieldMappings.transform` and `triggers.interval` — but ⛔ that eight is NOT a separate addend: the first six ARE the top-level tombstones counted above and the last two are already inside the `fieldMappings` and `triggers` counts, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | -| analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That is `dimensions.granularities` (read by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead). The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 9 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler exists anywhere), the three `description`s, `measures.format`, `dimensions.granularities`, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity. **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | +| analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That is `dimensions.granularities` (read by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead). The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 9 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler exists anywhere), the three `description`s, `measures.format`, `dimensions.granularities`, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity — RETIRED by #20300 (ADR-0049 enforce-or-remove) as `retiredKey()` tombstones on the member `strictObject`s, so those two rows STAY `dead` (the tombstone keeps the key in the walked shape) and the count does not move. **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every misleading entry carries `authorWarn` so authors hear about it at compile time diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json index 368db6aa743..e914671142a 100644 --- a/packages/spec/liveness/analytics_cube.json +++ b/packages/spec/liveness/analytics_cube.json @@ -32,8 +32,8 @@ "children": { "name": { "status": "dead", - "verifiedAt": "2026-09-17", - "note": "The RECORD KEY is the identity, not this field. `measures` is `z.record(z.string(), MetricSchema)`, and every consumer resolves a measure by its key: `analytics-service.ts#getMeta` publishes `${cube.name}.${key}`, `native-sql-strategy.ts#lookupMember` looks the member up in the `cube.measures` bag by key, `memory-analytics.ts` indexes `cube.measures[fieldName]`. Nothing reads the inner `name`. The dataset compiler WRITES it (`dataset-compiler.ts`: `const metric: Metric = { name: m.name, … }; measures[m.name] = metric`) — writing it equal to the key it files the metric under, which is exactly why no reader ever needed it. Authoring a `name` that DISAGREES with the key is the trap: the key wins everywhere and the authored value is inert. ⛔ Not an ADR-0049 deletion candidate as it stands: it is `z.string().regex(…)` and REQUIRED, so removing it breaks every authored cube to delete nothing; the honest repairs are to derive it from the key or to make the disagreement an authoring refusal, and both are decisions rather than sweeps." + "verifiedAt": "2026-09-28", + "note": "REMOVED 2026-09-28 (#20300, ADR-0049 enforce-or-remove) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources and stored cubes by the protocol-18 conversion `cube-member-inner-name-removed`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the record KEY in `measures` is the metric's name — to rename a metric, rename its key. The dead verdict was re-measured at retirement with a lit control: zero reads of a member's inner `name` in non-test source — `analytics-service.ts#getMeta` publishes `${cube.name}.${key}`, `native-sql-strategy.ts#lookupMember` and `memory-analytics.ts` index the bag by key — against four reads of the neighbouring `measure.label` / `dimension.label` in the same two `getMeta` projections. The earlier objection on this row (REQUIRED, so removing it breaks every authored cube to delete nothing) is answered by that conversion, which strips the key from every stored and built cube, and by the producers: `dataset-compiler.ts`, `CubeRegistry.inferFromObject` and `inferCubeFromQuery` stopped writing it in the same change. The disagreeing-value trap was live in-repo: the in-memory driver's fixtures authored `totalAmount: { name: 'total_amount' }` and queried `orders.totalAmount`." }, "label": { "status": "live", @@ -72,8 +72,8 @@ "children": { "name": { "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Same shape and same verdict as `measures.name`: `dimensions` is a `z.record`, every consumer resolves by the record KEY (`getMeta` publishes `${cube.name}.${key}`, `lookupMember` indexes the bag, `memory-analytics.ts` reads `cube.dimensions[fieldName]`), and the dataset compiler writes the inner `name` equal to the key it files the dimension under (`dimensions[d.name] = dim`). A `name` that disagrees with its key is inert. Not a deletion candidate for the reason given on `measures.name`." + "verifiedAt": "2026-09-28", + "note": "REMOVED 2026-09-28 (#20300, ADR-0049 enforce-or-remove) — the same tombstone, the same conversion (`cube-member-inner-name-removed`) and the same re-measurement as `measures.name`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the record KEY in `dimensions` is the dimension's name (`getMeta` publishes `${cube.name}.${key}`, `lookupMember` and `memory-analytics.ts#resolveDimension` index the bag by key) — to rename a dimension, rename its key." }, "label": { "status": "live", diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 124c4122cb5..e41eac1abad 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -1222,7 +1222,9 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 ).not.toContain(GUIDANCE_ROUTE_LEAF); // The lit leg and the dark leg of the same read: the retired key's rejection // carries a prescription bullet, an undeclared neighbour's does not. - const metric = { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' }; + // No inner `name`: it is a retiredKey() tombstone now, and carrying it would + // fail `lit` for that reason instead of the guidance-route key under test. + const metric = { label: 'Revenue', type: 'sum', sql: 'amount' }; const lit = MetricSchema.safeParse({ ...metric, [GUIDANCE_ROUTE_LEAF]: [{ sql: '1 = 1' }] }); const dark = MetricSchema.safeParse({ ...metric, zzNotPrescribed18301: 1 }); expect(lit.success, `writing '${GUIDANCE_ROUTE_LEAF}' is accepted again — re-pick the fixture`).toBe(false); diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts index 9958da01969..ce7add708a7 100644 --- a/packages/spec/src/api/analytics.test.ts +++ b/packages/spec/src/api/analytics.test.ts @@ -355,8 +355,8 @@ describe('AnalyticsMetadataResponseSchema — the CubeMeta[] projection (#6442)' { name: 'orders', sql: 'SELECT * FROM orders', - measures: { total_revenue: { name: 'total_revenue', label: 'Total Revenue', type: 'sum', sql: 'amount' } }, - dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } }, + measures: { total_revenue: { label: 'Total Revenue', type: 'sum', sql: 'amount' } }, + dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } }, }, ], }, diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index dbad7cebcf7..6de3e9d174e 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7673,7 +7673,6 @@ const metricFiltersRemoved: MetadataConversion = { measures: { // The card's measured shape: parsed, registered, returned unfiltered. closed_won_revenue: { - name: 'closed_won_revenue', label: 'Closed-Won Revenue', type: 'sum', sql: 'amount', @@ -7682,10 +7681,10 @@ const metricFiltersRemoved: MetadataConversion = { // A metric WITHOUT the key rides through untouched — the strip // dispatches on key presence, and the copy-on-write contract keeps // the reference. - order_count: { name: 'order_count', label: 'Orders', type: 'count', sql: 'id' }, + order_count: { label: 'Orders', type: 'count', sql: 'id' }, }, dimensions: { - stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, + stage: { label: 'Stage', type: 'string', sql: 'stage' }, }, }], }, @@ -7695,15 +7694,14 @@ const metricFiltersRemoved: MetadataConversion = { sql: 'orders', measures: { closed_won_revenue: { - name: 'closed_won_revenue', label: 'Closed-Won Revenue', type: 'sum', sql: 'amount', }, - order_count: { name: 'order_count', label: 'Orders', type: 'count', sql: 'id' }, + order_count: { label: 'Orders', type: 'count', sql: 'id' }, }, dimensions: { - stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, + stage: { label: 'Stage', type: 'string', sql: 'stage' }, }, }], }, @@ -7769,25 +7767,25 @@ const cubeSubDayGranularitiesRemoved: MetadataConversion = { analyticsCubes: [{ name: 'events', sql: 'events', - measures: { count: { name: 'count', label: 'Events', type: 'count', sql: 'id' } }, + measures: { count: { label: 'Events', type: 'count', sql: 'id' } }, dimensions: { // Mixed list — the sub-day names go, the rest stays in its order. created_at: { - name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at', + label: 'Created At', type: 'time', sql: 'created_at', granularities: ['hour', 'day', 'month'], }, // Sub-day ONLY — the key goes rather than becoming an empty list. touched_at: { - name: 'touched_at', label: 'Touched At', type: 'time', sql: 'touched_at', + label: 'Touched At', type: 'time', sql: 'touched_at', granularities: ['second', 'minute'], }, // Neither retired member nor the key at all: both ride through, and // the copy-on-write contract keeps the references. closed_at: { - name: 'closed_at', label: 'Closed At', type: 'time', sql: 'closed_at', + label: 'Closed At', type: 'time', sql: 'closed_at', granularities: ['day', 'week'], }, - stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, + stage: { label: 'Stage', type: 'string', sql: 'stage' }, }, }], }, @@ -7795,20 +7793,20 @@ const cubeSubDayGranularitiesRemoved: MetadataConversion = { analyticsCubes: [{ name: 'events', sql: 'events', - measures: { count: { name: 'count', label: 'Events', type: 'count', sql: 'id' } }, + measures: { count: { label: 'Events', type: 'count', sql: 'id' } }, dimensions: { created_at: { - name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at', + label: 'Created At', type: 'time', sql: 'created_at', granularities: ['day', 'month'], }, touched_at: { - name: 'touched_at', label: 'Touched At', type: 'time', sql: 'touched_at', + label: 'Touched At', type: 'time', sql: 'touched_at', }, closed_at: { - name: 'closed_at', label: 'Closed At', type: 'time', sql: 'closed_at', + label: 'Closed At', type: 'time', sql: 'closed_at', granularities: ['day', 'week'], }, - stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, + stage: { label: 'Stage', type: 'string', sql: 'stage' }, }, }], }, @@ -7899,8 +7897,8 @@ const cubeJoinSqlAndRelationshipRemoved: MetadataConversion = { { name: 'delivery', sql: 'task', - measures: { count: { name: 'count', label: 'Tasks', type: 'count', sql: 'id' } }, - dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } }, + measures: { count: { label: 'Tasks', type: 'count', sql: 'id' } }, + dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } }, joins: { // The persisted shape: `sql` was REQUIRED and `relationship` was // MATERIALIZED by the schema's own default, so this is what a cube @@ -7920,9 +7918,9 @@ const cubeJoinSqlAndRelationshipRemoved: MetadataConversion = { // A SECOND cube, so the notices have to distinguish two of them. name: 'billing', sql: 'invoice', - measures: { amount: { name: 'amount', label: 'Amount', type: 'sum', sql: 'amount' } }, + measures: { amount: { label: 'Amount', type: 'sum', sql: 'amount' } }, dimensions: { - issued_on: { name: 'issued_on', label: 'Issued', type: 'time', sql: 'issued_on' }, + issued_on: { label: 'Issued', type: 'time', sql: 'issued_on' }, }, joins: { // Only the cardinality — a join whose `sql` an author already @@ -7937,8 +7935,8 @@ const cubeJoinSqlAndRelationshipRemoved: MetadataConversion = { { name: 'delivery', sql: 'task', - measures: { count: { name: 'count', label: 'Tasks', type: 'count', sql: 'id' } }, - dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } }, + measures: { count: { label: 'Tasks', type: 'count', sql: 'id' } }, + dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } }, joins: { project: { name: 'showcase_project' }, owner: { name: 'sys_user' }, @@ -7947,9 +7945,9 @@ const cubeJoinSqlAndRelationshipRemoved: MetadataConversion = { { name: 'billing', sql: 'invoice', - measures: { amount: { name: 'amount', label: 'Amount', type: 'sum', sql: 'amount' } }, + measures: { amount: { label: 'Amount', type: 'sum', sql: 'amount' } }, dimensions: { - issued_on: { name: 'issued_on', label: 'Issued', type: 'time', sql: 'issued_on' }, + issued_on: { label: 'Issued', type: 'time', sql: 'issued_on' }, }, joins: { customer: { name: 'crm_account' }, @@ -7964,6 +7962,141 @@ const cubeJoinSqlAndRelationshipRemoved: MetadataConversion = { }, }; +/** + * `measures..name` and `dimensions..name` — the inner name a + * cube member used to REQUIRE (#20300, ADR-0049 enforce-or-remove; triage + * verdict RETIRE by the maintainer's criterion: Cube.dev and LookML key a + * member by its declared name, with no second inner name that can disagree). + * + * `measures` and `dimensions` are RECORDS, and every consumer resolves a member + * by its KEY — `AnalyticsService#getMeta` and the in-memory driver publish + * `.`, `NativeSQLStrategy#lookupMember` and the in-memory driver's + * `resolveMeasure` / `resolveDimension` index the bag by key. The inner `name` + * was a second copy of the identity that nothing read. + * + * ## Why a D2 strip, and why it strips a DISAGREEING value too + * + * The key was REQUIRED, so every cube artifact written from the old schema's + * parse output, and every stored `analytics_cube` row, carries it on every + * member — and after the tombstone the boot door refuses it + * (`ObjectStackDefinitionSchema` spreads `analyticsCubes: z.array(CubeSchema)`). + * Only the D2 table is replayed at the rehydration seams + * (`applyArtifactForwardConversions`, `applyConversionsToStoredItem`), so this + * entry is what keeps a deployed cube booting. + * + * A value EQUAL to its key is the lossless case the triage named. A value that + * DISAGREES is stripped as well, and that is not a guess about intent: the key + * already won everywhere, so the running system never saw the inner spelling, + * and deleting it changes no query and no discovery answer. Leaving it in place + * would change one — the cube would stop loading. What the strip cannot decide + * is which spelling the author MEANT, so the notice prints both (`from` carries + * the inner value, `to` names the key that stays) and the paired D3 entry + * `cube-member-inner-name-retired` addresses that judgement to the author. + * + * Members live one level below the collection item, in two records, so the + * walk runs per member — the `metric-filters-removed` shape, over both bags. + * The emitted path NAMES the cube, as `cube-join-sql-and-relationship-removed` + * does: an index into the author's `analyticsCubes[]` is a position, not a name. + */ +const cubeMemberInnerNameRemoved: MetadataConversion = { + id: 'cube-member-inner-name-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.4.0', + surface: 'analyticsCubes[].measures..name / analyticsCubes[].dimensions..name', + summary: + "cube member key 'name' removed from measures and dimensions (ADR-0049 enforce-or-remove — nothing read it: " + + 'every consumer resolves a member by its record KEY, published and queried as `.`. The ' + + 'record key is the member\'s name; to rename a member, rename its key)', + apply(stack, emit) { + return mapCollection(stack, 'analyticsCubes', (cube, path) => { + const where = typeof cube.name === 'string' ? `${path}(${cube.name})` : path; + let next: Dict = cube; + for (const bag of ['measures', 'dimensions'] as const) { + const members = cube[bag]; + if (!isDict(members)) continue; + let touched = false; + const nextMembers: Dict = { ...members }; + for (const [key, member] of Object.entries(members)) { + if (!isDict(member) || !('name' in member)) continue; + const { name, ...rest } = member; + const at = `${where}.${bag}.${key}.name`; + emit(name === key + ? { from: 'name', to: '(removed)', path: at } + : { from: `name ${JSON.stringify(name)}`, to: `(removed; the record key "${key}" is the name)`, path: at }); + nextMembers[key] = rest; + touched = true; + } + if (touched) next = { ...next, [bag]: nextMembers }; + } + return next; + }); + }, + fixture: { + before: { + analyticsCubes: [ + { + name: 'orders', + sql: 'orders', + measures: { + // The persisted shape: `name` was REQUIRED, and every producer in + // the repo wrote it equal to the key it filed the member under. + count: { name: 'count', label: 'Orders', type: 'count', sql: '*' }, + total_amount: { name: 'total_amount', label: 'Total', type: 'sum', sql: 'amount' }, + }, + dimensions: { + status: { name: 'status', label: 'Status', type: 'string', sql: 'status' }, + }, + }, + { + // A SECOND cube, so the notices have to distinguish two of them. + name: 'events', + sql: 'event', + measures: { + // DISAGREEING — the in-memory driver's own fixtures authored this + // shape and queried `events.eventCount`: the key won, the inner + // spelling was inert. Stripped, and the notice prints both. + eventCount: { name: 'event_count', label: 'Events', type: 'count', sql: '*' }, + }, + dimensions: { + // Already canonical — rides through untouched. The fixture's own + // control: the strip dispatches on key presence. + kind: { label: 'Kind', type: 'string', sql: 'kind' }, + }, + }, + ], + }, + after: { + analyticsCubes: [ + { + name: 'orders', + sql: 'orders', + measures: { + count: { label: 'Orders', type: 'count', sql: '*' }, + total_amount: { label: 'Total', type: 'sum', sql: 'amount' }, + }, + dimensions: { + status: { label: 'Status', type: 'string', sql: 'status' }, + }, + }, + { + name: 'events', + sql: 'event', + measures: { + eventCount: { label: 'Events', type: 'count', sql: '*' }, + }, + dimensions: { + kind: { label: 'Kind', type: 'string', sql: 'kind' }, + }, + }, + ], + }, + // Four notices, one per STRIPPED KEY: three equal names on `orders`, one + // disagreeing name on `events`, and none for the canonical `events.kind`. + expectedNotices: 4, + }, +}; + /** * `record:highlights` highlight-field `icon` — a declared, advertised key with * zero read points (#10054, ADR-0049 enforce-or-remove; maintainer ruling @@ -12488,6 +12621,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { expect( - reject(MetricSchema, { name: 'm', label: 'M', type: 'count', sql: '*', filters: [{ sql: 'x' }] }), + reject(MetricSchema, { label: 'M', type: 'count', sql: '*', filters: [{ sql: 'x' }] }), ).toContain('was removed in @objectstack/spec 17 (ADR-0049)'); }); @@ -163,7 +163,7 @@ describe('#4001 batch D — closed sites reject unknown keys where they live', ( expect( reject(CubeSchema, { ...CUBE, - dimensions: { d: { name: 'd', label: 'D', type: 'string', sql: 'd', primaryKey: true } }, + dimensions: { d: { label: 'D', type: 'string', sql: 'd', primaryKey: true } }, }), ).toContain('primaryKey'); }); @@ -246,7 +246,7 @@ describe('#4001 batch D — alias claims are true of the surfaces they point at' expect(Object.keys(CubeSchema.shape)).not.toContain('label'); expect(Object.keys(MetricSchema.shape)).toContain('label'); expect(Object.keys(DimensionSchema.shape)).toContain('label'); - expect(reject(MetricSchema, { name: 'm', title: 'M', label: 'M', type: 'count', sql: '*' })).toContain('label'); + expect(reject(MetricSchema, { title: 'M', label: 'M', type: 'count', sql: '*' })).toContain('label'); expect(reject(CubeSchema, { ...CUBE, label: 'Probe' })).toContain('title'); }); diff --git a/packages/spec/src/data/analytics.test.ts b/packages/spec/src/data/analytics.test.ts index 0c469cc58a9..8b49a915e4b 100644 --- a/packages/spec/src/data/analytics.test.ts +++ b/packages/spec/src/data/analytics.test.ts @@ -103,7 +103,7 @@ describe('TimeUpdateInterval', () => { // narrowing that reached only one of them would still parse a cube // offering a granularity no query may ask for. const dim = (granularities: string[]) => ({ - name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at', granularities, + label: 'Created At', type: 'time', sql: 'created_at', granularities, }); expect(DimensionSchema.safeParse(dim(['day', 'month'])).success).toBe(true); expect(DimensionSchema.safeParse(dim(['hour'])).success).toBe(false); @@ -113,19 +113,19 @@ describe('TimeUpdateInterval', () => { describe('MetricSchema', () => { it('should accept valid minimal metric', () => { const metric = MetricSchema.parse({ - name: 'total_revenue', label: 'Total Revenue', type: 'sum', sql: 'amount', }); - expect(metric.name).toBe('total_revenue'); expect(metric.type).toBe('sum'); + // The record key in `measures` is the metric's name; the parse output + // carries no inner copy of it. + expect(metric).not.toHaveProperty('name'); }); it('should accept metric with all fields', () => { const metric = MetricSchema.parse({ - name: 'avg_order_value', label: 'Average Order Value', description: 'Average revenue per order', type: 'avg', @@ -146,7 +146,6 @@ describe('MetricSchema', () => { // migration channel — not merely throw. it('rejects the removed `filters` key with the retirement prescription (#10414)', () => { expect(() => MetricSchema.parse({ - name: 'avg_order_value', label: 'Average Order Value', type: 'avg', sql: 'order_total', @@ -154,9 +153,22 @@ describe('MetricSchema', () => { })).toThrow(/`measures\.\.filters`.*removed in @objectstack\/spec 17 \(.*os migrate meta --from 17/s); }); + // #20300 (ADR-0049 enforce-or-remove): the inner `name` REMOVED. This file + // used to pin its snake_case regex ('should reject metric with invalid + // snake_case name') — a check on a value nothing read, because the record key + // was always the identity. The pin flips to the tombstone: EVERY value is + // refused, the key-equal one included, and the refusal carries the + // prescription. The full door-by-door pin lives in + // `cube-member-inner-name-retirement.test.ts`. + it('refuses the retired inner `name` with the prescription, whatever its value (#20300)', () => { + for (const name of ['total_revenue', 'TotalRevenue', '']) { + expect(() => MetricSchema.parse({ name, label: 'Total Revenue', type: 'sum', sql: 'amount' }), name) + .toThrow(/`measures\.\.name` was removed in @objectstack\/spec 17\.5\.0.*the record key is the metric's name.*os migrate meta --from 17/s); + } + }); + it('should apply defaults for optional fields', () => { const metric = MetricSchema.parse({ - name: 'count_users', label: 'User Count', type: 'count', sql: 'id', @@ -166,53 +178,38 @@ describe('MetricSchema', () => { expect(metric.format).toBeUndefined(); }); - it('should reject metric with invalid snake_case name', () => { - expect(() => MetricSchema.parse({ - name: 'TotalRevenue', - label: 'Total Revenue', - type: 'sum', - sql: 'amount', - })).toThrow(); - - expect(() => MetricSchema.parse({ - name: 'total-revenue', - label: 'Total Revenue', - type: 'sum', - sql: 'amount', - })).toThrow(); - }); - it('should reject metric without required fields', () => { + // No inner `name` in either literal: with one, the tombstone alone would + // refuse the parse and the missing field would never be what was measured. expect(() => MetricSchema.parse({ - name: 'revenue', label: 'Revenue', type: 'sum', })).toThrow(); expect(() => MetricSchema.parse({ - name: 'revenue', type: 'sum', sql: 'amount', })).toThrow(); + + // CONTROL: the same shape with every required field parses. + expect(MetricSchema.safeParse({ label: 'Revenue', type: 'sum', sql: 'amount' }).success).toBe(true); }); }); describe('DimensionSchema', () => { it('should accept valid minimal dimension', () => { const dim = DimensionSchema.parse({ - name: 'product_category', label: 'Product Category', type: 'string', sql: 'category', }); - expect(dim.name).toBe('product_category'); expect(dim.type).toBe('string'); + expect(dim).not.toHaveProperty('name'); }); it('should accept time dimension with granularities', () => { const dim = DimensionSchema.parse({ - name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at', @@ -225,7 +222,6 @@ describe('DimensionSchema', () => { it('should accept dimension with all fields', () => { const dim = DimensionSchema.parse({ - name: 'region', label: 'Region', description: 'Geographic region', type: 'geo', @@ -236,21 +232,23 @@ describe('DimensionSchema', () => { expect(dim.description).toBe('Geographic region'); }); - it('should reject dimension with invalid name', () => { - expect(() => DimensionSchema.parse({ - name: 'ProductCategory', - label: 'Product Category', - type: 'string', - sql: 'category', - })).toThrow(); + // #20300 — the same flip as the metric's (see that block): the snake_case + // check on the inner `name` pinned a value nothing read. + it('refuses the retired inner `name` with the prescription, whatever its value (#20300)', () => { + for (const name of ['product_category', 'ProductCategory']) { + expect(() => DimensionSchema.parse({ name, label: 'Product Category', type: 'string', sql: 'category' }), name) + .toThrow(/`dimensions\.\.name` was removed in @objectstack\/spec 17\.5\.0.*the record key is the dimension's name.*os migrate meta --from 17/s); + } }); it('should reject dimension without required fields', () => { expect(() => DimensionSchema.parse({ - name: 'category', label: 'Category', sql: 'category', })).toThrow(); + + // CONTROL: the same shape with its `type` parses. + expect(DimensionSchema.safeParse({ label: 'Category', type: 'string', sql: 'category' }).success).toBe(true); }); }); @@ -316,8 +314,8 @@ describe('a persisted cube heals at the door (#18612, ADR-0087 D2)', () => { analyticsCubes: [{ name: 'showcase_delivery', sql: 'showcase_task', - measures: { count: { name: 'count', label: 'Tasks', type: 'count', sql: '*' } }, - dimensions: { status: { name: 'status', label: 'Status', type: 'string', sql: 'status' } }, + measures: { count: { label: 'Tasks', type: 'count', sql: '*' } }, + dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } }, joins: { project: { name: 'showcase_project', @@ -364,8 +362,8 @@ describe('a persisted cube heals at the door (#18612, ADR-0087 D2)', () => { { name: 'billing_revenue', sql: 'showcase_invoice', - measures: { amount: { name: 'amount', label: 'Amount', type: 'sum', sql: 'amount' } }, - dimensions: { issued_on: { name: 'issued_on', label: 'Issued', type: 'time', sql: 'issued_on' } }, + measures: { amount: { label: 'Amount', type: 'sum', sql: 'amount' } }, + dimensions: { issued_on: { label: 'Issued', type: 'time', sql: 'issued_on' } }, joins: { account: { name: 'showcase_account', relationship: 'many_to_one' }, // Already canonical: the control that produces NO notice. @@ -391,7 +389,6 @@ describe('CubeSchema', () => { sql: 'SELECT * FROM orders', measures: { count: { - name: 'count', label: 'Order Count', type: 'count', sql: 'id', @@ -399,7 +396,6 @@ describe('CubeSchema', () => { }, dimensions: { status: { - name: 'status', label: 'Status', type: 'string', sql: 'status', diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 96666bdb5f4..f3fdb9b3a2a 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -21,6 +21,7 @@ import { DateGranularity } from './query.zod'; */ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; +import { retiredKey } from '../shared/retired-key'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; export const AggregationMetricType = z.enum([ 'count', @@ -147,6 +148,50 @@ export const TimeUpdateInterval = z.enum( ); export type TimeUpdateInterval = z.input; +/** + * The inner `name` a cube member used to REQUIRE — RETIRED (#20300, ADR-0049 + * enforce-or-remove; triage verdict RETIRE 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). + * + * `measures` and `dimensions` are RECORDS, and the record key was always the + * member's identity: `AnalyticsService#getMeta` and the in-memory driver + * publish every member as `${cube.name}.${key}`, + * `NativeSQLStrategy#lookupMember` and the in-memory driver's + * `resolveMeasure` / `resolveDimension` index the bag by key, and a query names + * the member that way. 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 key was a REQUIRED second copy of the identity that + * nothing read — and one that disagreed with its key was inert (the in-memory + * driver's own fixtures authored `totalAmount: { name: 'total_amount' }` and + * queried `orders.totalAmount`). + * + * A `retiredKey()` tombstone rather than a bare deletion, although the member + * shapes are `strictObject`s (the `action.aria` precedent): a bare delete is + * loud only as a generic unrecognized-key report, which cannot carry the + * prescription, and the tombstone also types the key `never` so a typed + * authoring site fails `tsc` first. Stored and built cubes carry the key — + * it was REQUIRED — so the ADR-0087 D2 conversion + * `cube-member-inner-name-removed` strips it at every rehydration seam, and the + * D3 entry `cube-member-inner-name-retired` carries the judgement a disagreeing + * value still owes its author. + */ +const CUBE_MEMBER_NAME_MIGRATE = + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + +const cubeMemberNameRemoved = (qualifiedKey: string, bag: 'measures' | 'dimensions', member: string) => + `\`${qualifiedKey}\` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — ` + + `it never had an effect: the record key is the ${member}'s name. Every consumer resolves a ${member} ` + + `by its key in \`${bag}\` (\`GET /analytics/meta\` publishes it as \`.\`, 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 ' + + `${member}, rename its key in \`${bag}\` — and every query, dashboard and report that names ` + + `\`.\`. ${CUBE_MEMBER_NAME_MIGRATE}`; + +const CUBE_METRIC_NAME_REMOVED = cubeMemberNameRemoved('measures..name', 'measures', 'metric'); +const CUBE_DIMENSION_NAME_REMOVED = cubeMemberNameRemoved('dimensions..name', 'dimensions', 'dimension'); + /** * Metric Schema * A quantitative measurement (e.g., "Total Revenue", "Average Order Value"). @@ -157,6 +202,10 @@ export type TimeUpdateInterval = z.input; * roots + `ObjectStackSchema` resolves the whole family reachable, with * `ObjectSchema` as positive control and a fresh uncarried shape as negative * control in the same run). + * + * A metric carries no name of its own: its key in the cube's `measures` record + * IS its name (the inner `name` was retired, see + * {@link CUBE_METRIC_NAME_REMOVED}). */ export const MetricSchema = lazySchema(() => strictObject( { @@ -188,7 +237,10 @@ export const MetricSchema = lazySchema(() => strictObject( }, }, { - name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique metric ID'), + // `name` REMOVED (#20300, ADR-0049 enforce-or-remove) — the record key in + // `measures` is the metric's name. See the module-level note above + // `CUBE_METRIC_NAME_REMOVED`. + name: retiredKey(CUBE_METRIC_NAME_REMOVED), label: z.string().describe('Human readable label'), description: z.string().optional(), @@ -214,6 +266,10 @@ export const MetricSchema = lazySchema(() => strictObject( * A categorical attribute to group by (e.g., "Product Category", "Order Date"). * * Strict as of #4001 batch D — same doors as {@link MetricSchema}. + * + * A dimension carries no name of its own: its key in the cube's `dimensions` + * record IS its name (the inner `name` was retired, see + * {@link CUBE_DIMENSION_NAME_REMOVED}). */ export const DimensionSchema = lazySchema(() => strictObject( { @@ -227,7 +283,10 @@ export const DimensionSchema = lazySchema(() => strictObject( }, }, { - name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique dimension ID'), + // `name` REMOVED (#20300, ADR-0049 enforce-or-remove) — the record key in + // `dimensions` is the dimension's name. See the module-level note above + // `CUBE_METRIC_NAME_REMOVED`. + name: retiredKey(CUBE_DIMENSION_NAME_REMOVED), label: z.string().describe('Human readable label'), description: z.string().optional(), @@ -299,9 +358,11 @@ const CUBE_JOIN_ON_REMOVED = * and `sql` outright (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): * the near-miss was the smaller half of the defect, because the DECLARED * spellings were being replaced just as silently. `MetricSchema.filters` above - * took the same route one shape over — every cube shape is a `strictObject`, so - * the route is strict deletion plus a `guidance` entry carrying the prescription, - * never a `retiredKey()` tombstone (the key leaves the walked shape entirely). + * took the same route one shape over: strict deletion plus a `guidance` entry + * carrying the prescription, so the key leaves the walked shape entirely. That + * is one of two routes on a `strictObject`, not the only one — the members' + * inner `name` (#20300) is a `retiredKey()` tombstone instead, which keeps the + * key in the walked shape and types it `never` for `tsc`. */ export const CubeJoinSchema = lazySchema(() => strictObject( { @@ -375,8 +436,14 @@ export const CubeSchema = lazySchema(() => strictObject( sql: z.string().describe('Base SQL statement or Table Name'), /** Semantic Definitions */ - measures: z.record(z.string(), MetricSchema).describe('Quantitative metrics'), - dimensions: z.record(z.string(), DimensionSchema).describe('Qualitative attributes'), + measures: z.record(z.string(), MetricSchema).describe( + 'Quantitative metrics, keyed by metric name: the record key IS the metric\'s name, published and ' + + 'queried as `.`. A metric declares no inner `name`.', + ), + dimensions: z.record(z.string(), DimensionSchema).describe( + 'Qualitative attributes, keyed by dimension name: the record key IS the dimension\'s name, published ' + + 'and queried as `.`. A dimension declares no inner `name`.', + ), /** Relationships */ joins: z.record(z.string(), CubeJoinSchema).optional(), diff --git a/packages/spec/src/data/cube-member-inner-name-retirement.test.ts b/packages/spec/src/data/cube-member-inner-name-retirement.test.ts new file mode 100644 index 00000000000..b9df0c2258d --- /dev/null +++ b/packages/spec/src/data/cube-member-inner-name-retirement.test.ts @@ -0,0 +1,660 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The inner `name` on cube measures and dimensions RETIRED (#20300) — ADR-0049 + * enforce-or-remove, graded RETIRE by the maintainer's criterion for + * declared-but-unenforced families: does a mainstream platform have the + * capability? 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. + * + * Measured before removal, with a lit control, and recorded on the tombstone + * in `analytics.zod.ts` and on both ledger rows: 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. + * + * Bookkeeping shapes, pinned below: + * 1. `retiredKey()` tombstones on `MetricSchema` and `DimensionSchema` — both + * `strictObject`s, so the tombstone (the `action.aria` posture) is what + * carries the prescription instead of a bare unknown-key verdict, and it + * types the key `never` for `tsc`. + * 2. D2 conversion `cube-member-inner-name-removed` (step 18), a delete over + * every member of every `analyticsCubes[]` entry, retired from the load + * path: a live author is refused, a stored or built cube replays clean. + * The key was REQUIRED, so every persisted cube carries it — the + * conversion is what keeps a deployed cube booting. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries `data/Metric:name` and + * `data/Dimension:name`, and the family's D3 entry is + * `cube-member-inner-name-retired` (one D3 entry per retirement family, + * even when D2 is lossless). + * 4. The liveness rows STAY, `dead`, because the tombstone keeps the key in + * the walked shape (`check:liveness` is the judge of that half). + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues + * carry `code` and `path` but no ADR-0112 `status` — that envelope belongs to + * the authoring door, `defineStack`, which is pinned with its `code` and + * `status` below. Everywhere else: refusal, the issue `code`, the `path` + * naming the key, and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { applyConversions, collectConversionNotices } from '../conversions/apply'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack, ObjectStackDefinitionSchema } from '../stack.zod'; +import { + CubeSchema, + DimensionSchema, + MetricSchema, + defineCube, + type Cube, + type Dimension, + type Metric, +} from './analytics.zod'; + +const CONVERSION_ID = 'cube-member-inner-name-removed'; + +/** A well-formed metric and dimension — every live key an author commonly writes, not the retired one. */ +const METRIC = { label: 'Orders', type: 'count', sql: '*' } as const; +const DIMENSION = { label: 'Status', type: 'string', sql: 'status' } as const; + +/** A well-formed cube over those members. */ +const CUBE = { + name: 'orders', + sql: 'orders', + measures: { count: METRIC }, + dimensions: { status: DIMENSION }, +} as const; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself below. +const METRIC_PRESCRIPTION = + /`measures\.\.name` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049 enforce-or-remove\).*the record key is the metric's name.*Delete the key\..*rename its key in `measures`.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\./s; +const DIMENSION_PRESCRIPTION = + /`dimensions\.\.name` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049 enforce-or-remove\).*the record key is the dimension's name.*Delete the key\..*rename its key in `dimensions`.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\./s; + +/** What a 17.4-or-earlier parse emitted: the REQUIRED inner name on every member, equal to its key. */ +const persistedCube = () => ({ + name: 'orders', + sql: 'orders', + measures: { + count: { name: 'count', ...METRIC }, + total_amount: { name: 'total_amount', label: 'Total', type: 'sum', sql: 'amount' }, + }, + dimensions: { status: { name: 'status', ...DIMENSION } }, +}); + +describe('cube member inner name retirement — the tombstone, at every door that carries a cube', () => { + it('the metric schema refuses `name` at its path, with the prescription', () => { + const r = MetricSchema.safeParse({ name: 'count', ...METRIC }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['name']); + expect(issue.message).toMatch(METRIC_PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue.message.startsWith('`measures..name` was removed')).toBe(true); + }); + + it('the dimension schema refuses `name` at its path, with the prescription', () => { + const r = DimensionSchema.safeParse({ name: 'status', ...DIMENSION }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['name']); + expect(issue.message).toMatch(DIMENSION_PRESCRIPTION); + expect(issue.message.startsWith('`dimensions..name` was removed')).toBe(true); + }); + + it('refuses every value shape — the key-equal one included; the tombstone accepts only absence', () => { + for (const value of ['count', 'total_count', 'TotalCount', '', null, 1]) { + const r = MetricSchema.safeParse({ name: value, ...METRIC }); + expect(r.success, `measures name: ${JSON.stringify(value)}`).toBe(false); + if (r.success) continue; + expect(r.error.issues[0]!.path).toEqual(['name']); + expect(r.error.issues[0]!.message).toMatch(METRIC_PRESCRIPTION); + } + for (const value of ['status', 'order_status', null]) { + const r = DimensionSchema.safeParse({ name: value, ...DIMENSION }); + expect(r.success, `dimensions name: ${JSON.stringify(value)}`).toBe(false); + if (r.success) continue; + expect(r.error.issues[0]!.message).toMatch(DIMENSION_PRESCRIPTION); + } + }); + + it('the cube schema refuses it at measures..name and dimensions..name — one issue per member', () => { + const r = CubeSchema.safeParse(persistedCube()); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => [i.code, i.path])).toEqual([ + ['invalid_type', ['measures', 'count', 'name']], + ['invalid_type', ['measures', 'total_amount', 'name']], + ['invalid_type', ['dimensions', 'status', 'name']], + ]); + }); + + it('the `analytics_cube` write door (the registry binding) refuses it', () => { + // `getMetadataTypeSchema('analytics_cube')` is what `saveMetaItem` validates a + // `PUT /api/v1/meta/analytics_cube` body against; a rebinding to some other + // shape would pass the pins above and still accept the key in production. + const door = getMetadataTypeSchema('analytics_cube'); + expect(door, 'no schema bound for `analytics_cube`').toBeDefined(); + expect(door).toBe(CubeSchema); + const r = door!.safeParse({ ...CUBE, measures: { count: { name: 'count', ...METRIC } } }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.path).toEqual(['measures', 'count', 'name']); + expect(r.error.issues[0]!.message).toMatch(METRIC_PRESCRIPTION); + }); + + it('`defineCube()` refuses it with the prescription', () => { + expect(() => defineCube({ ...CUBE, dimensions: { status: { name: 'status', ...DIMENSION } } } as never)) + .toThrow(DIMENSION_PRESCRIPTION); + }); + + it('the authoring door, defineStack, refuses it with the STACK_SCHEMA_INVALID envelope — never rewrites it', () => { + const stack = (cube: Record) => ({ + manifest: { id: 'com.example.cube-member-name', name: 'cube_member_name', version: '1.0.0', type: 'app' }, + analyticsCubes: [cube], + }); + let thrown: unknown; + try { + defineStack(stack({ ...CUBE, measures: { count: { name: 'count', ...METRIC } } }) as never); + } catch (e) { + thrown = e; + } + const refusal = thrown as { code?: string; status?: number; issues?: Array<{ path: PropertyKey[]; message: string }> }; + expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal?.status).toBe(422); + expect(refusal.issues).toHaveLength(1); + expect(refusal.issues?.[0]?.path).toEqual(['analyticsCubes', 0, 'measures', 'count', 'name']); + expect(refusal.issues?.[0]?.message).toMatch(METRIC_PRESCRIPTION); + // CONTROL: the same stack without the key is accepted by the same door. + expect(() => defineStack(stack({ ...CUBE }) as never)).not.toThrow(); + }); + + it('CONTROL: the same cube without the key passes every door, every live key intact', () => { + const cube = CubeSchema.safeParse(CUBE); + expect(cube.success).toBe(true); + if (!cube.success) return; + // Absence stays absence: the tombstone materializes nothing. + expect(cube.data.measures.count).not.toHaveProperty('name'); + expect(cube.data.dimensions.status).not.toHaveProperty('name'); + expect(cube.data.measures.count).toEqual(METRIC); + expect(cube.data.dimensions.status).toEqual(DIMENSION); + expect(defineCube(CUBE).measures.count).toEqual(METRIC); + }); + + it('the did-you-mean never offers the tombstone: a near-miss `nme` is refused as unknown, not steered onto `name`', () => { + // `strictObject` excludes a key that accepts nothing from its suggestion + // pool (`acceptsNothing`), so an author who typed `nme` is not told to + // write the retired key and meet a second refusal. + const r = MetricSchema.safeParse({ ...METRIC, nme: 'count' }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toContain('`nme`'); + expect(issue.message).not.toMatch(/`nme` → `name`/); + }); + + it('fails tsc at the authoring site: the input type of `name` is `never` on both members', () => { + const metric: Metric = { + ...METRIC, + // @ts-expect-error — `name` is a retiredKey() tombstone: its input type is `never`. + name: 'count', + }; + const dimension: Dimension = { + ...DIMENSION, + // @ts-expect-error — `name` is a retiredKey() tombstone: its input type is `never`. + name: 'status', + }; + const cube: Cube = { ...CUBE, measures: { count: metric }, dimensions: { status: dimension } }; + // The parse channel agrees with the type channel on the same literals. + expect(() => MetricSchema.parse(metric)).toThrow(METRIC_PRESCRIPTION); + expect(() => DimensionSchema.parse(dimension)).toThrow(DIMENSION_PRESCRIPTION); + expect(CubeSchema.safeParse(cube).success).toBe(false); + }); +}); + +describe('cube member inner name retirement — the D2 conversion', () => { + it('a STORED `analytics_cube` row carrying the key replays clean through the rehydration seam', () => { + // The seam wraps an `analytics_cube` row as `{ analyticsCubes: [row] }` and + // replays the full chain, retired entries included. + const notices: { conversionId?: string; path?: string; from?: string }[] = []; + const rehydrated = applyConversionsToStoredItem('analytics_cube', persistedCube(), { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string; from?: string }), + }) as ReturnType; + + expect(notices.map((n) => [n.conversionId, n.path, n.from])).toEqual([ + [CONVERSION_ID, 'analyticsCubes[0](orders).measures.count.name', 'name'], + [CONVERSION_ID, 'analyticsCubes[0](orders).measures.total_amount.name', 'name'], + [CONVERSION_ID, 'analyticsCubes[0](orders).dimensions.status.name', 'name'], + ]); + // CONTROL: every live key on the same members survives byte-for-byte. + expect(rehydrated.measures.count).toEqual(METRIC); + expect(rehydrated.measures.total_amount).toEqual({ label: 'Total', type: 'sum', sql: 'amount' }); + expect(rehydrated.dimensions.status).toEqual(DIMENSION); + // And the rehydrated row is exactly what the write door accepts now. + expect(CubeSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('a persisted artifact is REFUSED at the boot door before the conversion and ACCEPTED after it', () => { + const artifact = () => ({ analyticsCubes: [persistedCube()] }); + const before = ObjectStackDefinitionSchema.safeParse(artifact()); + expect(before.success).toBe(false); + expect(JSON.stringify(before.error?.issues ?? [])).toContain('was removed in @objectstack/spec 17.5.0'); + + const healed = applyConversions(artifact(), { includeRetired: true }); + const after = ObjectStackDefinitionSchema.safeParse(healed); + expect( + after.success, + `expected the converted artifact to parse; got ${JSON.stringify(after.error?.issues ?? [])}`, + ).toBe(true); + }); + + it('LIT CONTROL — a member that was always wrong is refused on BOTH sides of the conversion', () => { + // `nme` is not the retired key, so the strip leaves it and the door still + // refuses — which is what makes the leg above a reading and not a tautology. + const artifact = () => { + const cube = persistedCube() as Record; + cube.measures = { count: { name: 'count', nme: 'count', ...METRIC } }; + return { analyticsCubes: [cube] }; + }; + expect(ObjectStackDefinitionSchema.safeParse(artifact()).success).toBe(false); + const healed = applyConversions(artifact(), { includeRetired: true }); + expect(ObjectStackDefinitionSchema.safeParse(healed).success).toBe(false); + }); + + it('strips a DISAGREEING name too — the key already won — and the notice prints both spellings', () => { + // The in-memory driver's own fixtures authored this shape and queried + // `orders.totalAmount`: the key was the name every consumer used, so the + // strip changes no answer; the notice is what tells the author there were two. + const input = { + analyticsCubes: [{ + name: 'orders', + sql: 'orders', + measures: { totalAmount: { name: 'total_amount', label: 'Total', type: 'sum', sql: 'amount' } }, + dimensions: { status: DIMENSION }, + }], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + const mine = notices.filter((n) => n.conversionId === CONVERSION_ID); + expect(mine.map((n) => [n.path, n.from, n.to])).toEqual([ + [ + 'analyticsCubes[0](orders).measures.totalAmount.name', + 'name "total_amount"', + '(removed; the record key "totalAmount" is the name)', + ], + ]); + const cube = (stack.analyticsCubes as Array<{ measures: Record; dimensions: unknown }>)[0]!; + // The KEY stays — the conversion never re-keys a member. + expect(Object.keys(cube.measures)).toEqual(['totalAmount']); + expect(cube.measures.totalAmount).toEqual({ label: 'Total', type: 'sum', sql: 'amount' }); + // Copy-on-write: the untouched bag is handed back by reference. + expect(cube.dimensions).toBe(input.analyticsCubes[0]!.dimensions); + }); + + it('strips only the members that carry the key, and is idempotent by construction', () => { + const input = { + analyticsCubes: [ + persistedCube(), + // A cube already canonical: no notice, and handed back by reference. + { ...CUBE }, + ], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(3); + const cubes = stack.analyticsCubes as unknown[]; + expect(cubes[1]).toBe(input.analyticsCubes[1]); + + // Idempotence, measured: a second replay converts nothing and hands the + // input back by reference. + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const input = { analyticsCubes: [persistedCube()] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); +}); + +describe('cube member inner name retirement — ADR-0087 registration', () => { + it('declares both keys under major 18, wires the D2 into the step-18 chain and carries the family D3 entry', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('data/Metric:name'); + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('data/Dimension:name'); + const step = MIGRATIONS_BY_MAJOR[18]!; + expect(step.conversionIds).toContain(CONVERSION_ID); + const d3 = step.semantic.find((s) => s.id === 'cube-member-inner-name-retired'); + expect(d3, 'the family D3 entry').toBeDefined(); + // The D3 entry names its D2 by its whole id. + expect(d3!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(d3!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types the key `never` on +// `Metric` and `Dimension`, so every TYPED authoring site fails to compile. The +// residue is what `tsc` never judges: JSON, YAML, MD/MDX code fences, untyped +// `.js`, and TS literals typed `any` / `unknown` — which is exactly where the +// in-repo producers were (`Record` bags in the ad-hoc cube mints). +// This walk covers that residue across five roots, each already declared for +// `@objectstack/spec#test` in `scripts/cross-package-test-inputs.mjs` and +// mirrored in `turbo.json` — the same roots and extensions the RLS `tags` pin +// walks. +// +// ⭐ `name` is the commonest key in this tree, so a textual matcher would be +// all noise. The matcher is STRUCTURAL instead: an offender is one object +// literal (or one YAML mapping) whose OWN keys include `name`, `sql` and `type` +// — a cube member — AND whose enclosing object is the value of a `measures` or +// `dimensions` key (or of a variable so named). A cube's own `name`, a join's +// `name`, and a dataset's `measures: [{ name, … }]` ARRAY are not matched. +// +// The bound, stated: a member assembled by SPREAD (`{ ...base, name }`), +// assigned into a bag (`bag[key] = { name, … }`) or built under computed keys +// is invisible to a text walk; `docs/**`, `.claude/**`, `.github/**` and the +// repo-root files are outside the radius. +describe('tree-scoped absence: no cube member inside the declared radius still carries an inner name', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` only the non-code extensions are scanned AND declared. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key on a member. + */ + const EXCLUDED = new Set([ + // This pin names the key to assert its absence, and its conversion legs + // author the pre-retirement persisted shape on purpose. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement member on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // The liveness ledgers key one row per schema PROPERTY, so the `measures` / + // `dimensions` blocks of `analytics_cube.json` carry `name`, `sql` and + // `type` rows side by side under `children` — a classification of the + // shape, not an authoring, and the two `name` rows are the ones the + // tombstone route requires to STAY. + 'packages/spec/liveness/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + const BAGS = new Set(['measures', 'dimensions']); + const isMemberKeySet = (keys: Set): boolean => keys.has('name') && keys.has('sql') && keys.has('type'); + + /** + * One pass over JS/TS/JSON text: a stack of bracket frames, each `{` frame + * collecting its OWN keys — an identifier or quoted string in key position + * (after `{` or `,`) followed by `:` — and remembering the key (or the + * `const X =` binding) that opened it. Strings and comments are skipped; a + * single- or double-quoted string never spans a line, so a mis-lexed quote + * (a regex literal) costs at most that line. Returns the 1-based line of each + * closing brace whose frame is a member carrying `name` inside a bag. + * (Copied from the RLS `tags` pin and extended with the opener, never + * imported: a shared helper would be one point of failure for every + * retirement's absence leg at once.) + */ + const lexOffenders = (text: string): number[] => { + const out: number[] = []; + const stack: { kind: string; keys: Set; opener?: string; lastKey?: string }[] = []; + let lastSig = ''; + // `const measures: Record = { … }` — the binding a `{` opens + // under is the name after `const` / `let` / `var`, whatever type annotation + // sits between it and the `=`. + let prevIdent = ''; + let pendingDecl = ''; + let boundName = ''; + let line = 1; + let i = 0; + const n = text.length; + while (i < n) { + const c = text[i]!; + if (c === '\n') { line += 1; i += 1; continue; } + if (c === '/' && text[i + 1] === '/') { while (i < n && text[i] !== '\n') i += 1; continue; } + if (c === '/' && text[i + 1] === '*') { + i += 2; + while (i < n && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') line += 1; i += 1; } + i += 2; + continue; + } + if (c === '"' || c === "'" || c === '`') { + const start = i; + i += 1; + while (i < n && text[i] !== c) { + if (text[i] === '\\') i += 1; + else if (text[i] === '\n') { if (c !== '`') break; line += 1; } + i += 1; + } + const token = text.slice(start + 1, i); + i += 1; + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + const top = stack[stack.length - 1]; + if (c !== '`' && text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ',')) { + top.keys.add(token); + top.lastKey = token; + } + lastSig = 'str'; + continue; + } + if (/[A-Za-z_$]/.test(c)) { + const start = i; + while (i < n && /[\w$]/.test(text[i]!)) i += 1; + const token = text.slice(start, i); + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + const top = stack[stack.length - 1]; + if (text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ',')) { + top.keys.add(token); + top.lastKey = token; + } + if (prevIdent === 'const' || prevIdent === 'let' || prevIdent === 'var') pendingDecl = token; + prevIdent = token; + lastSig = 'id'; + continue; + } + if (c === '=' && text[i + 1] !== '=' && text[i + 1] !== '>' && !'=!<>+-*/%&|^'.includes(text[i - 1] ?? '')) { + boundName = pendingDecl; + pendingDecl = ''; + } + if (c === '{' || c === '[' || c === '(') { + const parent = stack[stack.length - 1]; + let opener: string | undefined; + if (c === '{' && lastSig === ':' && parent?.kind === '{') opener = parent.lastKey; + else if (c === '{' && lastSig === '=') opener = boundName; + stack.push({ kind: c, keys: new Set(), opener }); + } else if (c === '}' || c === ']' || c === ')') { + const frame = stack.pop(); + const parent = stack[stack.length - 1]; + if ( + frame?.kind === '{' && c === '}' && isMemberKeySet(frame.keys) + && parent?.kind === '{' && parent.opener !== undefined && BAGS.has(parent.opener) + ) out.push(line); + } + if (!/\s/.test(c)) lastSig = c; + i += 1; + } + return out; + }; + + /** + * YAML: a mapping's OWN keys are the key lines at one column, bounded by a + * line at a smaller column. A member is the mapping under a key whose own + * parent key is `measures` / `dimensions`. Returns the 1-based line of each + * `name` key whose mapping is such a member. + */ + const yamlOffenders = (text: string): number[] => { + const rows = text.split('\n').map((raw, idx) => { + const m = /^(\s*)(-\s+)?([A-Za-z_][\w]*)\s*:/.exec(raw); + return m ? { idx, col: m[1]!.length + (m[2]?.length ?? 0), key: m[3]!, item: Boolean(m[2]) } : null; + }); + const parentOf = (at: number): number => { + const row = rows[at]!; + for (let k = at - 1; k >= 0; k -= 1) { + const r = rows[k]; + if (r && r.col < row.col) return k; + } + return -1; + }; + const out: number[] = []; + rows.forEach((row, at) => { + if (!row || row.key !== 'name' || row.item) return; + const keys = new Set([row.key]); + for (let k = at - 1; k >= 0; k -= 1) { + const r = rows[k]; + if (!r) continue; + if (r.col < row.col) break; + if (r.col === row.col) keys.add(r.key); + } + for (let k = at + 1; k < rows.length; k += 1) { + const r = rows[k]; + if (!r) continue; + if (r.col < row.col) break; + if (r.col === row.col) keys.add(r.key); + } + if (!isMemberKeySet(keys)) return; + const member = parentOf(at); + if (member < 0 || rows[member]!.item) return; + const bag = parentOf(member); + if (bag >= 0 && BAGS.has(rows[bag]!.key)) out.push(row.idx + 1); + }); + return out; + }; + + /** MD/MDX: only fenced code is judged — prose mentions are not authorings. */ + const markdownOffenders = (text: string): number[] => { + const out: number[] = []; + const fence = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + for (let m = fence.exec(text); m; m = fence.exec(text)) { + const lang = m[1]!.toLowerCase(); + const body = m[2]!; + const offset = text.slice(0, m.index).split('\n').length; + const hits = lang === 'yaml' || lang === 'yml' ? yamlOffenders(body) : lexOffenders(body); + for (const h of hits) out.push(offset + h); + } + return out; + }; + + /** Cheap pre-filter: a file that never spells a bag and a member's halves cannot hold an offender. */ + const mayHoldMember = (text: string): boolean => + (text.includes('measures') || text.includes('dimensions')) && text.includes('name') && text.includes('sql'); + + const offendersIn = (ext: string, text: string): number[] => { + if (!mayHoldMember(text)) return []; + if (ext === '.yaml' || ext === '.yml') return yamlOffenders(text); + if (ext === '.md' || ext === '.mdx') return markdownOffenders(text); + return lexOffenders(text); + }; + + const vanished: string[] = []; + /** Tolerates ONLY a path's disappearance mid-walk; every other fault is re-raised. */ + const readIfPresent = (full: string, rel: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + vanished.push(rel); + return undefined; + } + }; + + it('the matcher finds a member authoring and ignores every neighbouring shape (anti-vacuity)', () => { + // Offenders — the member spelling, in each syntax the walk reads. + expect(offendersIn('.ts', "({ measures: { count: { name: 'count', label: 'C', type: 'count', sql: '*' } } })")).toEqual([1]); + expect(offendersIn('.ts', "defineCube({\n name: 'o',\n sql: 'o',\n dimensions: {\n status: {\n name: 'status',\n type: 'string',\n sql: 'status',\n },\n },\n});")).toEqual([9]); + expect(offendersIn('.ts', "const measures: Record = { total: { name: 'total', type: 'sum', sql: 'amount' } };")).toEqual([1]); + expect(offendersIn('.json', '{ "measures": { "count": { "name": "count", "type": "count", "sql": "*" } } }')).toEqual([1]); + expect(offendersIn('.yaml', 'measures:\n count:\n name: count\n type: count\n sql: "*"\n')).toEqual([3]); + expect(offendersIn('.md', "Prose.\n\n```ts\n({ dimensions: { d: { type: 'string', sql: 'd', name: 'd' } } });\n```\n")).toEqual([4]); + expect(offendersIn('.md', 'Prose.\n\n```yaml\ndimensions:\n d:\n type: string\n sql: d\n name: d\n```\n')).toEqual([8]); + // Neighbours that must NOT match. + // A member without the key; a cube's OWN `name` beside its `sql`. + expect(offendersIn('.ts', "({ name: 'orders', sql: 'orders', measures: { count: { label: 'C', type: 'count', sql: '*' } } })")).toEqual([]); + // A join's `name` under `joins`, and a dataset's measures ARRAY. + expect(offendersIn('.ts', "({ joins: { owner: { name: 'sys_user', type: 'x', sql: 'y' } } })")).toEqual([]); + expect(offendersIn('.ts', "defineDataset({ measures: [{ name: 'n', aggregate: 'count', type: 'x', sql: 'y' }] })")).toEqual([]); + // The member SHAPE declared in a schema (no enclosing bag), and the + // `getMeta` projection that WRITES a published `name` into an array. + expect(offendersIn('.ts', "strictObject({ surface: 'm' }, { name: retiredKey(X), type: T, sql: z.string() })")).toEqual([]); + expect(offendersIn('.ts', "({ measures: Object.entries(c.measures).map(([k, m]) => ({ name: k, type: m.type, sql: 'x' })) })")).toEqual([]); + // Prose and quoted strings are not authorings. + expect(offendersIn('.md', 'A member once took `name`, `type` and `sql` under `measures`.')).toEqual([]); + expect(offendersIn('.ts', "const s = \"measures: { c: { name: 'c', type: 'count', sql: '*' } }\";")).toEqual([]); + // A YAML `name` whose mapping is not a bag member. + expect(offendersIn('.yaml', 'joins:\n owner:\n name: sys_user\n type: x\n sql: y\n')).toEqual([]); + }); + + it('a path that VANISHES mid-walk is not a finding, and every other read fault still is', () => { + const before = vanished.length; + const gone = path.join(REPO_ROOT, 'packages/spec/does-not-exist.bundled_probe.mjs'); + expect(fs.existsSync(gone)).toBe(false); + expect(readIfPresent(gone, 'probe/gone')).toBeUndefined(); + expect(vanished.slice(before)).toEqual(['probe/gone']); + expect(readIfPresent(fileURLToPath(import.meta.url), THIS_FILE)).toContain('tree-scoped absence'); + expect(() => readIfPresent(path.join(REPO_ROOT, 'packages/spec'), 'probe/dir')).toThrow(); + expect(vanished.length).toBe(before + 1); + }); + + it('no cube member carrying an inner name survives inside the declared radius', () => { + const offenders: string[] = []; + let visited = 0; + let bagBearing = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + const text = readIfPresent(full, rel); + if (text === undefined) continue; + if (mayHoldMember(text) && text.includes('measures') && text.includes('dimensions')) bagBearing += 1; + for (const lineNo of offendersIn(ext, text)) offenders.push(`${rel}:${lineNo}`); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk covered the tree, and the files that CAN hold a + // member were really read. + expect(visited).toBeGreaterThan(1000); + expect(bagBearing).toBeGreaterThan(20); + expect(offenders, 'a cube member carrying an inner `name` means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__Dimension__name.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__Dimension__name.ts new file mode 100644 index 00000000000..43087cdeac3 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__Dimension__name.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #20300 — the same ruling, the same diff and the same route as +// `data/Metric:name`: `Dimension.name` was REQUIRED and read by nothing, because +// `dimensions` is a record and every consumer resolves a dimension by its KEY +// (`getMeta` publishes `.`, `lookupMember` and the in-memory driver's +// `resolveDimension` index the bag by key). A `retiredKey()` tombstone on the +// `strictObject`; the D2 conversion `cube-member-inner-name-removed` strips it +// from stored and built cubes, and the D3 record is +// `cube-member-inner-name-retired`. +export const entry = 'data/Dimension:name'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__Metric__name.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__Metric__name.ts new file mode 100644 index 00000000000..3c90074952d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__Metric__name.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #20300 — ADR-0049 enforce-or-remove (triage verdict RETIRE 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). `Metric.name` was REQUIRED and read by nothing: `measures` is a +// record, and every consumer resolves a metric by its KEY — `getMeta` +// publishes `.`, `NativeSQLStrategy#lookupMember` and the in-memory +// driver index the bag by key. Measured with a lit control: zero reads of a +// member's inner `name` in non-test source, four reads of the neighbouring +// `measure.label` / `dimension.label` in the same `getMeta` projections. +// +// `retiredKey()` on a `strictObject`, for the prescription and the `tsc` +// channel (the `ui/Action:aria` precedent). Registered under 18, not 17: the +// tombstone ships on the 17.x line (launch-window convention — accept-set +// narrowings ride minor releases) and the prescription lives at the major +// boundary where `migrate meta` users look. The D2 conversion +// `cube-member-inner-name-removed` strips the key wherever the chain is +// replayed; it is owed because the key was REQUIRED, so every stored or built +// cube carries it. The D3 record is `cube-member-inner-name-retired`. +export const entry = 'data/Metric:name'; diff --git a/packages/spec/src/migrations/entries/semantic/18.cube-member-inner-name-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cube-member-inner-name-retired.ts new file mode 100644 index 00000000000..856fe218d69 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.cube-member-inner-name-retired.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20300 — ADR-0049 enforce-or-remove (triage verdict RETIRE) — the D3 entry of +// the `cube-member-inner-name-removed` family (one D3 entry per retirement +// family, even when D2 is lossless). Registered keys: `data/Metric:name` and +// `data/Dimension:name`. The strip changes no query and no discovery answer; +// what it cannot decide is which of two DISAGREEING names an author meant. +export const entry: SemanticMigration = { + id: 'cube-member-inner-name-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: + 'analyticsCubes[].measures..name / analyticsCubes[].dimensions..name — the ' + + 'inner name a cube member used to require', + replacement: + 'The record key. `measures` and `dimensions` are records, and the key a member is declared under ' + + 'IS its name: the analytics API publishes it as `.` and a query names it that way. To ' + + 'rename a member, rename its key.', + reason: + 'The D2 conversion `cube-member-inner-name-removed` deletes the inner `name` from every metric and ' + + 'dimension of every cube, and the delete is lossless in behaviour: every consumer — discovery, both ' + + 'query strategies, the in-memory driver — resolves a member by its record key, so the inner value ' + + 'was never read. Where it EQUALED its key there is nothing left to decide. Where it DISAGREED, the ' + + 'key was already the name every query, dashboard and report used, and the inner value was a spelling ' + + 'nothing read; the conversion notice prints both. Only the author can say whether the disagreeing ' + + 'spelling was the one they meant — in which case the member must be re-keyed, and every consumer ' + + 'that names `.` changes with it — or a stale copy to drop.', + acceptanceCriteria: + 'No metric or dimension of any cube carries `name`; the parse refuses it with the prescription. ' + + 'For every conversion notice whose `from` shows a name that differed from its record key, the ' + + 'author has either kept the key (nothing else changes) or re-keyed the member to the intended ' + + 'name and updated every query, dashboard and report that names `.`. ' + + '`GET /api/v1/analytics/meta` lists each member as `.` exactly as before the upgrade.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 996b735d43d..3dc41b4da2e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5175,6 +5175,23 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'on the retirement PR, taken over the seat recommendation to keep the connector D2, on ' + 'the reading that customers do not upgrade major by major in order.', }, + { + id: 'cube-member-inner-name-retired', + order: 47, + text: + 'It retires the inner `name` on cube members — `measures..name` and ' + + '`dimensions..name` (ADR-0049 enforce-or-remove) — by the mainstream criterion: ' + + 'Cube.dev and LookML key a member by its declared name, with no second inner name that can ' + + 'disagree. Both member bags are records, and every consumer already resolved a member by its ' + + 'record KEY, publishing and querying it as `.`; the REQUIRED inner copy was read by ' + + 'nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey ' + + 'tombstones on `MetricSchema` and `DimensionSchema`, and because the key was required, every ' + + 'stored or built cube carries it: the D2 conversion `cube-member-inner-name-removed` strips it ' + + 'from every member of every cube, retired from the load path, and its notice prints a ' + + 'disagreeing value beside the key that stays. Its D3 record is the semantic entry ' + + '`cube-member-inner-name-retired`, which asks the author of a disagreeing name which spelling ' + + 'they meant.', + }, { id: 'cube-metric-filters-retired', order: 10, @@ -7898,6 +7915,38 @@ const step18: MigrationStep = { + 'Nothing else regresses: `joins..name` is unchanged, and it is what both the joined ' + 'table and the per-object RLS/tenant read scope are resolved from.', }, + // #20300 — ADR-0049 enforce-or-remove (triage verdict RETIRE) — the D3 entry of + // the `cube-member-inner-name-removed` family (one D3 entry per retirement + // family, even when D2 is lossless). Registered keys: `data/Metric:name` and + // `data/Dimension:name`. The strip changes no query and no discovery answer; + // what it cannot decide is which of two DISAGREEING names an author meant. + { + id: 'cube-member-inner-name-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: + 'analyticsCubes[].measures..name / analyticsCubes[].dimensions..name — the ' + + 'inner name a cube member used to require', + replacement: + 'The record key. `measures` and `dimensions` are records, and the key a member is declared under ' + + 'IS its name: the analytics API publishes it as `.` and a query names it that way. To ' + + 'rename a member, rename its key.', + reason: + 'The D2 conversion `cube-member-inner-name-removed` deletes the inner `name` from every metric and ' + + 'dimension of every cube, and the delete is lossless in behaviour: every consumer — discovery, both ' + + 'query strategies, the in-memory driver — resolves a member by its record key, so the inner value ' + + 'was never read. Where it EQUALED its key there is nothing left to decide. Where it DISAGREED, the ' + + 'key was already the name every query, dashboard and report used, and the inner value was a spelling ' + + 'nothing read; the conversion notice prints both. Only the author can say whether the disagreeing ' + + 'spelling was the one they meant — in which case the member must be re-keyed, and every consumer ' + + 'that names `.` changes with it — or a stale copy to drop.', + acceptanceCriteria: + 'No metric or dimension of any cube carries `name`; the parse refuses it with the prescription. ' + + 'For every conversion notice whose `from` shows a name that differed from its record key, the ' + + 'author has either kept the key (nothing else changes) or re-keyed the member to the intended ' + + 'name and updated every query, dashboard and report that names `.`. ' + + '`GET /api/v1/analytics/meta` lists each member as `.` exactly as before the upgrade.', + }, // #10414 (ADR-0049 enforce-or-remove) — the D3 entry of the // `metric-filters-removed` family (ruling B on #17152: one D3 entry per // retirement family, even when D2 is lossless). The unknown-keys entry @@ -19005,6 +19054,15 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `currencyConfig` on objects and object extensions — including the `2` the // old `.overwrite()` baked into parse output. 'data/CurrencyConfig:precision', + // #20300 — the same ruling, the same diff and the same route as + // `data/Metric:name`: `Dimension.name` was REQUIRED and read by nothing, because + // `dimensions` is a record and every consumer resolves a dimension by its KEY + // (`getMeta` publishes `.`, `lookupMember` and the in-memory driver's + // `resolveDimension` index the bag by key). A `retiredKey()` tombstone on the + // `strictObject`; the D2 conversion `cube-member-inner-name-removed` strips it + // from stored and built cubes, and the D3 record is + // `cube-member-inner-name-retired`. + 'data/Dimension:name', // #14478 — maintainer ruling 2026-09-02 ("ruled B"): the unit of a // duration-shaped `z.number()` key lives in the key name, and no existing // offender is grandfathered. `DriverOptions.timeout` said "Timeout in ms" in @@ -19140,6 +19198,25 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // conversion `metric-filters-removed`, which strips the key from every metric // in `analyticsCubes[].measures`. 'data/Metric:filters', + // #20300 — ADR-0049 enforce-or-remove (triage verdict RETIRE 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). `Metric.name` was REQUIRED and read by nothing: `measures` is a + // record, and every consumer resolves a metric by its KEY — `getMeta` + // publishes `.`, `NativeSQLStrategy#lookupMember` and the in-memory + // driver index the bag by key. Measured with a lit control: zero reads of a + // member's inner `name` in non-test source, four reads of the neighbouring + // `measure.label` / `dimension.label` in the same `getMeta` projections. + // + // `retiredKey()` on a `strictObject`, for the prescription and the `tsc` + // channel (the `ui/Action:aria` precedent). Registered under 18, not 17: the + // tombstone ships on the 17.x line (launch-window convention — accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look. The D2 conversion + // `cube-member-inner-name-removed` strips the key wherever the chain is + // replayed; it is owed because the key was REQUIRED, so every stored or built + // cube carries it. The D3 record is `cube-member-inner-name-retired`. + 'data/Metric:name', // #15680 (stack card 5/6 of #14478) — ruling B. `NoSQLQueryOptions.timeout` // said "Query timeout (ms)" in prose and nothing else, directly beside // `batchSize`, a plain row COUNT: two bare numbers side by side, one carrying a diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 6bcb4491d0e..4a51683aa2b 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -27,6 +27,7 @@ "src/api/export-job-family-retirement.test.ts", "src/api/rest-api-config-dead-keys-retirement.test.ts", "src/data/api-methods-batch-conformance.test.ts", + "src/data/cube-member-inner-name-retirement.test.ts", "src/data/currency-mode-family-closure.pin.test.ts", "src/identity/position-delegatable-enforcer.pin.test.ts", "src/integration/connector-connection-timeout-retirement.test.ts", diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md index 570341f3340..c43b389e3a6 100644 --- a/skills/objectstack-ui/rules/dashboards.md +++ b/skills/objectstack-ui/rules/dashboards.md @@ -447,15 +447,15 @@ export const opportunityCube = defineCube({ sql: 'opportunity', // underlying object name (snake_case) public: true, measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: '*' }, - amount: { name: 'amount', label: 'Total Amount', type: 'sum', sql: 'amount', format: 'currency' }, + count: { label: 'Count', type: 'count', sql: '*' }, + amount: { label: 'Total Amount', type: 'sum', sql: 'amount', format: 'currency' }, }, dimensions: { - stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, - close_date: { name: 'close_date', label: 'Close', type: 'time', sql: 'close_date', + stage: { label: 'Stage', type: 'string', sql: 'stage' }, + close_date: { label: 'Close', type: 'time', sql: 'close_date', granularities: ['day', 'week', 'month', 'quarter', 'year'] }, - account_industry: { name: 'account_industry', label: 'Industry', type: 'string', sql: 'account.industry' }, - owner: { name: 'owner', label: 'Owner', type: 'string', sql: 'owner' }, + account_industry: { label: 'Industry', type: 'string', sql: 'account.industry' }, + owner: { label: 'Owner', type: 'string', sql: 'owner' }, }, }); ```