Skip to content

Commit 03cdb9a

Browse files
feat(spec,service-analytics): GET /analytics/meta publishes cube and member descriptions and the measure format (stage 3 of #20282) (#20736)
Part of #20282 Stage 3: `CubeMeta` and `GET /analytics/meta` carry the cube, measure and dimension `description` and the measure `format` the definition declares, projected by `service-analytics#getMeta`. The three `description` liveness rows move to `live`, and the showcase `done_rate` format becomes `'0.0%'`. Clause-②: yes (widening) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4dfff17 commit 03cdb9a

14 files changed

Lines changed: 295 additions & 41 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-analytics': minor
4+
---
5+
6+
`GET /api/v1/analytics/meta` now publishes an analytics cube's `description`, each measure's and dimension's `description`, and each measure's `format`, when the cube definition declares them (#20282).
7+
8+
Clause-②: yes (widening)
9+
10+
- `CubeMeta` (`@objectstack/spec/contracts`) gains an optional `description` on the cube and on each measure and dimension, and an optional `format` on each measure. `AnalyticsMetadataResponseSchema` declares the same members. A definition that declares none of them is published exactly as before.
11+
- `AnalyticsService.getMeta` copies what the definition declares and fills in nothing. A cube compiled from a dataset carries each dataset measure's `format` and no `description`.
12+
- The liveness ledger rows `analytics_cube.description`, `measures.description` and `dimensions.description` move from `dead` to `live`.
13+
14+
This supersedes one sentence of this release's note on an authored cube's measure `format` and `granularities`: it says `GET /api/v1/analytics/meta` is unchanged and keeps `name`, `type` and `title`. With this change `/meta` also publishes each measure's declared `format`. A client that formats a result column still reads `format` off the query result's `fields[]`.

‎content/docs/api/data-api.mdx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -461,12 +461,13 @@ never as guaranteed present.
461461
The **cube's metric/dimension definition** (`MetricSchema.label` / `MetricSchema.format`,
462462
`DimensionSchema.label`) is the *declaration* surface, not a substitute for this one.
463463
`GET /analytics/meta` below publishes a deliberately narrow projection of it — `name`,
464-
`type` and `title` (the definition's `label`) only, with `format` dropped
464+
`type`, `title` (the definition's `label`), `description`, and on a measure its declared
465+
`format`; never `sql` or `granularities`
465466
([#6442](https://github.com/objectstack-ai/objectstack/issues/6442)). So a client that
466467
renders table headers or formats amounts takes `label` / `format` / `currency` /
467-
`percentScale` off the query result's `fields[]`: those are resolved server-side (the
468-
currency chain below, the percent-scale chain), and `format` is not reachable through
469-
the metadata endpoint at all.
468+
`percentScale` off the query result's `fields[]`: those are resolved server-side, per
469+
column (the currency chain below, the percent-scale chain), and `currency` and
470+
`percentScale` are not reachable through the metadata endpoint at all.
470471

471472
The currency chain runs on the dataset query (`POST /analytics/dataset/query`). A
472473
monetary measure column takes the measure's own `currency`; then, only when the source

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const result = AnalyticsEndpoint.parse(data);
4747
| **success** | `boolean` | ✅ | Operation success status |
4848
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false |
4949
| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata |
50-
| **data** | `{ name: string; title?: string; measures: object[]; dimensions: object[] }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. |
50+
| **data** | `{ name: string; title?: string; description?: string; measures: object[]; … }[]` | ✅ | Available cubes, each as the `CubeMeta` discovery projection — the cube name, its title and description, and the measures/dimensions a client may name in a query. A bare array: there is no `cubes` wrapper object, and no cube `sql` is published. |
5151

5252
### Nested Shape: `AnalyticsMetadataResponse.error`
5353

@@ -78,8 +78,9 @@ const result = AnalyticsEndpoint.parse(data);
7878
| :--- | :--- | :--- | :--- |
7979
| **name** | `string` | ✅ | Cube name |
8080
| **title** | `string` | optional | Human-readable cube title |
81-
| **measures** | `{ name: string; type: string; title?: string }[]` | ✅ | Measures this cube accepts in `/analytics/query` |
82-
| **dimensions** | `{ name: string; type: string; title?: string }[]` | ✅ | Dimensions this cube accepts in `/analytics/query` |
81+
| **description** | `string` | optional | Cube description, projected from the cube definition's `description` |
82+
| **measures** | `{ name: string; type: string; title?: string; description?: string; … }[]` | ✅ | Measures this cube accepts in `/analytics/query` |
83+
| **dimensions** | `{ name: string; type: string; title?: string; description?: string }[]` | ✅ | Dimensions this cube accepts in `/analytics/query` |
8384

8485

8586
---

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ Type: `[string, string]`
148148
| **description** | `string` | optional | |
149149
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
150150
| **sql** | `string` | ✅ | SQL expression or field reference |
151-
| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results; not published by GET /analytics/meta. |
151+
| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta. |
152152

153153
### Nested Shape: `Cube.dimensions[string]`
154154

@@ -221,7 +221,7 @@ Type: `[string, string]`
221221
| **description** | `string` | optional | |
222222
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
223223
| **sql** | `string` | ✅ | SQL expression or field reference |
224-
| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results; not published by GET /analytics/meta. |
224+
| **format** | `string` | optional | Display format for this measure's result column: a numeral pattern such as "$0,0.00" or "0.0%". Relayed verbatim as fields[].format on POST /analytics/query results, and on the measure by GET /analytics/meta. |
225225

226226

227227
---

‎examples/app-showcase/src/data/analytics/showcase.cube.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ export const DeliveryCube = defineCube({
3939
label: 'Done Rate (%)',
4040
type: 'number',
4141
sql: "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)",
42-
format: 'percent',
42+
// A numeral pattern, the vocabulary `fields[].format` documents: `%` marks a
43+
// percent, `.0` one decimal. The value above is in percentage points (0-100).
44+
format: '0.0%',
4345
},
4446
},
4547
dimensions: {
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `analytics_cube.description`, `measures.description`, `dimensions.description`
5+
* and `measures.format` on the discovery door — `getMeta()`, which
6+
* `GET /api/v1/analytics/meta` hands to `success()` verbatim.
7+
*
8+
* The `CubeMeta` projection carried `{ name, type, title }` per member and
9+
* `{ name, title }` per cube, so an authored description reached no reader at
10+
* all, and a measure's `format` reached only the query door's `fields[]`.
11+
*
12+
* What this file pins:
13+
* - an authored cube's descriptions (cube, measure, dimension) and a measure's
14+
* `format` are published exactly as written;
15+
* - a definition that declares none of them publishes no key — the projection
16+
* copies, it never fills in;
17+
* - a compiled dataset's cube publishes the `format` the dataset compiler
18+
* copies from each dataset measure, and no `description`: the compiler
19+
* writes none, so there is none to publish;
20+
* - a hidden cube stays hidden, descriptions or not.
21+
*/
22+
23+
import { describe, it, expect, vi } from 'vitest';
24+
import { CubeSchema, type Cube } from '@objectstack/spec/data';
25+
import { DatasetSchema } from '@objectstack/spec/ui';
26+
import { AnalyticsService } from '../analytics-service.js';
27+
28+
const silentLogger = {
29+
info: vi.fn(),
30+
debug: vi.fn(),
31+
warn: vi.fn(),
32+
error: vi.fn(),
33+
child: vi.fn().mockReturnThis(),
34+
} as any;
35+
36+
/** Parsed the way `defineCube()` and `defineStack({ analyticsCubes })` parse an authored cube. */
37+
const described: Cube = CubeSchema.parse({
38+
name: 'orders',
39+
title: 'Orders',
40+
description: 'Every order placed in the shop, one row per order.',
41+
sql: 'shop_order',
42+
measures: {
43+
revenue: {
44+
label: 'Revenue',
45+
description: 'Sum of order amounts, in the order currency.',
46+
type: 'sum',
47+
sql: 'amount',
48+
format: '$0,0.00',
49+
},
50+
margin: { label: 'Margin', type: 'avg', sql: 'margin', format: '0.0%' },
51+
count: { label: 'Orders', description: 'Number of orders.', type: 'count', sql: '*' },
52+
},
53+
dimensions: {
54+
status: { label: 'Status', description: 'Fulfilment status of the order.', type: 'string', sql: 'status' },
55+
placed_at: { label: 'Placed', type: 'time', sql: 'placed_at' },
56+
},
57+
});
58+
59+
/** The same kind of cube with no description and no format anywhere. */
60+
const bare: Cube = CubeSchema.parse({
61+
name: 'refunds',
62+
sql: 'shop_refund',
63+
measures: { count: { label: 'Refunds', type: 'count', sql: '*' } },
64+
dimensions: { reason: { label: 'Reason', type: 'string', sql: 'reason' } },
65+
});
66+
67+
function service(cubes: Cube[]) {
68+
return new AnalyticsService({ logger: silentLogger, cubes });
69+
}
70+
71+
describe('getMeta — an authored cube publishes its descriptions and measure formats', () => {
72+
it('copies the cube, measure and dimension descriptions and each measure format as written', async () => {
73+
const [cube] = await service([described]).getMeta('orders');
74+
75+
expect(cube).toEqual({
76+
name: 'orders',
77+
title: 'Orders',
78+
description: 'Every order placed in the shop, one row per order.',
79+
measures: [
80+
{
81+
name: 'orders.revenue',
82+
type: 'sum',
83+
title: 'Revenue',
84+
description: 'Sum of order amounts, in the order currency.',
85+
format: '$0,0.00',
86+
},
87+
{ name: 'orders.margin', type: 'avg', title: 'Margin', format: '0.0%' },
88+
{ name: 'orders.count', type: 'count', title: 'Orders', description: 'Number of orders.' },
89+
],
90+
dimensions: [
91+
{ name: 'orders.status', type: 'string', title: 'Status', description: 'Fulfilment status of the order.' },
92+
{ name: 'orders.placed_at', type: 'time', title: 'Placed' },
93+
],
94+
});
95+
});
96+
97+
it('publishes no key a definition does not declare', async () => {
98+
const [cube] = await service([bare]).getMeta('refunds');
99+
100+
expect(cube).not.toHaveProperty('description');
101+
expect(cube.measures[0]).not.toHaveProperty('description');
102+
expect(cube.measures[0]).not.toHaveProperty('format');
103+
expect(cube.dimensions[0]).not.toHaveProperty('description');
104+
// What the projection did publish before this change is unchanged.
105+
expect(cube.measures[0]).toEqual({ name: 'refunds.count', type: 'count', title: 'Refunds' });
106+
expect(cube.dimensions[0]).toEqual({ name: 'refunds.reason', type: 'string', title: 'Reason' });
107+
});
108+
109+
it('answers the listing and the by-name lookup alike', async () => {
110+
const svc = service([described, bare]);
111+
const listed = (await svc.getMeta()).find((c) => c.name === 'orders');
112+
const [byName] = await svc.getMeta('orders');
113+
expect(listed).toEqual(byName);
114+
expect(listed?.description).toBe('Every order placed in the shop, one row per order.');
115+
});
116+
117+
it('still omits a cube declared `public: false`, whatever it describes', async () => {
118+
const hidden = CubeSchema.parse({ ...described, name: 'hidden_orders', public: false });
119+
const svc = service([hidden]);
120+
expect(await svc.getMeta()).toEqual([]);
121+
expect(await svc.getMeta('hidden_orders')).toEqual([]);
122+
});
123+
});
124+
125+
describe('getMeta — a compiled dataset cube publishes what the compiler wrote', () => {
126+
const dataset = DatasetSchema.parse({
127+
name: 'order_metrics',
128+
label: 'Order metrics',
129+
description: 'Order KPIs for the sales dashboard.',
130+
object: 'shop_order',
131+
include: [],
132+
dimensions: [{ name: 'status', field: 'status', type: 'string' }],
133+
measures: [
134+
{ name: 'revenue', aggregate: 'sum', field: 'amount', format: '$0,0' },
135+
{ name: 'count', aggregate: 'count' },
136+
],
137+
});
138+
139+
it('carries each dataset measure `format`, and no description (the compiler writes none)', async () => {
140+
const svc = service([]);
141+
svc.registerDataset(dataset);
142+
const [cube] = await svc.getMeta('order_metrics');
143+
144+
expect(cube.measures.find((m) => m.name === 'order_metrics.revenue')?.format).toBe('$0,0');
145+
expect(cube.measures.find((m) => m.name === 'order_metrics.count')).not.toHaveProperty('format');
146+
// The dataset's own `description` is not copied onto the cube it compiles to
147+
// (`dataset-compiler.ts#compileDataset`), and a dataset measure or dimension
148+
// has no `description` key to copy. Nothing is filled in on this path.
149+
expect(cube).not.toHaveProperty('description');
150+
for (const member of [...cube.measures, ...cube.dimensions]) {
151+
expect(member).not.toHaveProperty('description');
152+
}
153+
});
154+
});

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,8 @@ function withDeclaredGranularityDefaults(query: AnalyticsQuery, cube: Cube | und
487487
* column of a result with the display `format` its cube measure declares.
488488
*
489489
* `fields[].format` is the presentation surface a client formats amounts from
490-
* (`AnalyticsResult`); `GET /analytics/meta` deliberately does not carry it.
490+
* (`AnalyticsResult`), column by column; `GET /analytics/meta` publishes the
491+
* same declared value per measure (`getMeta`), with no column to attach it to.
491492
* The compiled-dataset path fills it from the dataset's own measure
492493
* (`enrichResultColumns`), and the dataset compiler copies that same value onto
493494
* the cube it mints — so for a compiled dataset the value read here is the one
@@ -2307,6 +2308,13 @@ export class AnalyticsService implements IAnalyticsService {
23072308
* Only cubes the analytics API exposes are listed: a cube declared
23082309
* `public: false` is omitted, and asking for it by name answers `[]` — the
23092310
* same answer as a name no cube has (`cube-visibility.ts`).
2311+
*
2312+
* `description` (cube, measure, dimension) and a measure's `format` are the
2313+
* registered definition's own values, copied when declared and left off when
2314+
* not — never filled in. An authored cube carries what its author wrote; a
2315+
* compiled dataset's cube carries the `format` the dataset compiler copies
2316+
* from each dataset measure, and no `description`, because the compiler
2317+
* writes none.
23102318
*/
23112319
async getMeta(cubeName?: string): Promise<CubeMeta[]> {
23122320
const cubes = (cubeName
@@ -2317,15 +2325,19 @@ export class AnalyticsService implements IAnalyticsService {
23172325
return cubes.map(cube => ({
23182326
name: cube.name,
23192327
title: cube.title,
2328+
...(cube.description === undefined ? {} : { description: cube.description }),
23202329
measures: Object.entries(cube.measures).map(([key, measure]) => ({
23212330
name: `${cube.name}.${key}`,
23222331
type: measure.type,
23232332
title: measure.label,
2333+
...(measure.description === undefined ? {} : { description: measure.description }),
2334+
...(measure.format === undefined ? {} : { format: measure.format }),
23242335
})),
23252336
dimensions: Object.entries(cube.dimensions).map(([key, dimension]) => ({
23262337
name: `${cube.name}.${key}`,
23272338
type: dimension.type,
23282339
title: dimension.label,
2340+
...(dimension.description === undefined ? {} : { description: dimension.description }),
23292341
})),
23302342
}));
23312343
}

0 commit comments

Comments
 (0)