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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/20282-analytics-cube-meta-descriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': minor
'@objectstack/service-analytics': minor
---

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

Clause-②: yes (widening)

- `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.
- `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`.
- The liveness ledger rows `analytics_cube.description`, `measures.description` and `dimensions.description` move from `dead` to `live`.

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[]`.
9 changes: 5 additions & 4 deletions content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -461,12 +461,13 @@ never as guaranteed present.
The **cube's metric/dimension definition** (`MetricSchema.label` / `MetricSchema.format`,
`DimensionSchema.label`) is the *declaration* surface, not a substitute for this one.
`GET /analytics/meta` below publishes a deliberately narrow projection of it — `name`,
`type` and `title` (the definition's `label`) only, with `format` dropped
`type`, `title` (the definition's `label`), `description`, and on a measure its declared
`format`; never `sql` or `granularities`
([#6442](https://github.com/objectstack-ai/objectstack/issues/6442)). So a client that
renders table headers or formats amounts takes `label` / `format` / `currency` /
`percentScale` off the query result's `fields[]`: those are resolved server-side (the
currency chain below, the percent-scale chain), and `format` is not reachable through
the metadata endpoint at all.
`percentScale` off the query result's `fields[]`: those are resolved server-side, per
column (the currency chain below, the percent-scale chain), and `currency` and
`percentScale` are not reachable through the metadata endpoint at all.

The currency chain runs on the dataset query (`POST /analytics/dataset/query`). A
monetary measure column takes the measure's own `currency`; then, only when the source
Expand Down
7 changes: 4 additions & 3 deletions content/docs/references/api/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const result = AnalyticsEndpoint.parse(data);
| **success** | `boolean` | ✅ | Operation success status |
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata |
| **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. |
| **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. |

### Nested Shape: `AnalyticsMetadataResponse.error`

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


---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Type: `[string, string]`
| **description** | `string` | optional | |
| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct' \| 'number' \| 'string' \| 'boolean'>` | ✅ | |
| **sql** | `string` | ✅ | SQL expression or field reference |
| **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. |
| **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. |

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

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


---
Expand Down
4 changes: 3 additions & 1 deletion examples/app-showcase/src/data/analytics/showcase.cube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ export const DeliveryCube = defineCube({
label: 'Done Rate (%)',
type: 'number',
sql: "SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)",
format: 'percent',
// A numeral pattern, the vocabulary `fields[].format` documents: `%` marks a
// percent, `.0` one decimal. The value above is in percentage points (0-100).
format: '0.0%',
},
},
dimensions: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `analytics_cube.description`, `measures.description`, `dimensions.description`
* and `measures.format` on the discovery door — `getMeta()`, which
* `GET /api/v1/analytics/meta` hands to `success()` verbatim.
*
* The `CubeMeta` projection carried `{ name, type, title }` per member and
* `{ name, title }` per cube, so an authored description reached no reader at
* all, and a measure's `format` reached only the query door's `fields[]`.
*
* What this file pins:
* - an authored cube's descriptions (cube, measure, dimension) and a measure's
* `format` are published exactly as written;
* - a definition that declares none of them publishes no key — the projection
* copies, it never fills in;
* - a compiled dataset's cube publishes the `format` the dataset compiler
* copies from each dataset measure, and no `description`: the compiler
* writes none, so there is none to publish;
* - a hidden cube stays hidden, descriptions or not.
*/

import { describe, it, expect, vi } from 'vitest';
import { CubeSchema, type Cube } from '@objectstack/spec/data';
import { DatasetSchema } from '@objectstack/spec/ui';
import { AnalyticsService } from '../analytics-service.js';

const silentLogger = {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn().mockReturnThis(),
} as any;

/** Parsed the way `defineCube()` and `defineStack({ analyticsCubes })` parse an authored cube. */
const described: Cube = CubeSchema.parse({
name: 'orders',
title: 'Orders',
description: 'Every order placed in the shop, one row per order.',
sql: 'shop_order',
measures: {
revenue: {
label: 'Revenue',
description: 'Sum of order amounts, in the order currency.',
type: 'sum',
sql: 'amount',
format: '$0,0.00',
},
margin: { label: 'Margin', type: 'avg', sql: 'margin', format: '0.0%' },
count: { label: 'Orders', description: 'Number of orders.', type: 'count', sql: '*' },
},
dimensions: {
status: { label: 'Status', description: 'Fulfilment status of the order.', type: 'string', sql: 'status' },
placed_at: { label: 'Placed', type: 'time', sql: 'placed_at' },
},
});

/** The same kind of cube with no description and no format anywhere. */
const bare: Cube = CubeSchema.parse({
name: 'refunds',
sql: 'shop_refund',
measures: { count: { label: 'Refunds', type: 'count', sql: '*' } },
dimensions: { reason: { label: 'Reason', type: 'string', sql: 'reason' } },
});

function service(cubes: Cube[]) {
return new AnalyticsService({ logger: silentLogger, cubes });
}

describe('getMeta — an authored cube publishes its descriptions and measure formats', () => {
it('copies the cube, measure and dimension descriptions and each measure format as written', async () => {
const [cube] = await service([described]).getMeta('orders');

expect(cube).toEqual({
name: 'orders',
title: 'Orders',
description: 'Every order placed in the shop, one row per order.',
measures: [
{
name: 'orders.revenue',
type: 'sum',
title: 'Revenue',
description: 'Sum of order amounts, in the order currency.',
format: '$0,0.00',
},
{ name: 'orders.margin', type: 'avg', title: 'Margin', format: '0.0%' },
{ name: 'orders.count', type: 'count', title: 'Orders', description: 'Number of orders.' },
],
dimensions: [
{ name: 'orders.status', type: 'string', title: 'Status', description: 'Fulfilment status of the order.' },
{ name: 'orders.placed_at', type: 'time', title: 'Placed' },
],
});
});

it('publishes no key a definition does not declare', async () => {
const [cube] = await service([bare]).getMeta('refunds');

expect(cube).not.toHaveProperty('description');
expect(cube.measures[0]).not.toHaveProperty('description');
expect(cube.measures[0]).not.toHaveProperty('format');
expect(cube.dimensions[0]).not.toHaveProperty('description');
// What the projection did publish before this change is unchanged.
expect(cube.measures[0]).toEqual({ name: 'refunds.count', type: 'count', title: 'Refunds' });
expect(cube.dimensions[0]).toEqual({ name: 'refunds.reason', type: 'string', title: 'Reason' });
});

it('answers the listing and the by-name lookup alike', async () => {
const svc = service([described, bare]);
const listed = (await svc.getMeta()).find((c) => c.name === 'orders');
const [byName] = await svc.getMeta('orders');
expect(listed).toEqual(byName);
expect(listed?.description).toBe('Every order placed in the shop, one row per order.');
});

it('still omits a cube declared `public: false`, whatever it describes', async () => {
const hidden = CubeSchema.parse({ ...described, name: 'hidden_orders', public: false });
const svc = service([hidden]);
expect(await svc.getMeta()).toEqual([]);
expect(await svc.getMeta('hidden_orders')).toEqual([]);
});
});

describe('getMeta — a compiled dataset cube publishes what the compiler wrote', () => {
const dataset = DatasetSchema.parse({
name: 'order_metrics',
label: 'Order metrics',
description: 'Order KPIs for the sales dashboard.',
object: 'shop_order',
include: [],
dimensions: [{ name: 'status', field: 'status', type: 'string' }],
measures: [
{ name: 'revenue', aggregate: 'sum', field: 'amount', format: '$0,0' },
{ name: 'count', aggregate: 'count' },
],
});

it('carries each dataset measure `format`, and no description (the compiler writes none)', async () => {
const svc = service([]);
svc.registerDataset(dataset);
const [cube] = await svc.getMeta('order_metrics');

expect(cube.measures.find((m) => m.name === 'order_metrics.revenue')?.format).toBe('$0,0');
expect(cube.measures.find((m) => m.name === 'order_metrics.count')).not.toHaveProperty('format');
// The dataset's own `description` is not copied onto the cube it compiles to
// (`dataset-compiler.ts#compileDataset`), and a dataset measure or dimension
// has no `description` key to copy. Nothing is filled in on this path.
expect(cube).not.toHaveProperty('description');
for (const member of [...cube.measures, ...cube.dimensions]) {
expect(member).not.toHaveProperty('description');
}
});
});
14 changes: 13 additions & 1 deletion packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,8 @@ function withDeclaredGranularityDefaults(query: AnalyticsQuery, cube: Cube | und
* column of a result with the display `format` its cube measure declares.
*
* `fields[].format` is the presentation surface a client formats amounts from
* (`AnalyticsResult`); `GET /analytics/meta` deliberately does not carry it.
* (`AnalyticsResult`), column by column; `GET /analytics/meta` publishes the
* same declared value per measure (`getMeta`), with no column to attach it to.
* The compiled-dataset path fills it from the dataset's own measure
* (`enrichResultColumns`), and the dataset compiler copies that same value onto
* the cube it mints — so for a compiled dataset the value read here is the one
Expand Down Expand Up @@ -2307,6 +2308,13 @@ export class AnalyticsService implements IAnalyticsService {
* Only cubes the analytics API exposes are listed: a cube declared
* `public: false` is omitted, and asking for it by name answers `[]` — the
* same answer as a name no cube has (`cube-visibility.ts`).
*
* `description` (cube, measure, dimension) and a measure's `format` are the
* registered definition's own values, copied when declared and left off when
* not — never filled in. An authored cube carries what its author wrote; a
* compiled dataset's cube carries the `format` the dataset compiler copies
* from each dataset measure, and no `description`, because the compiler
* writes none.
*/
async getMeta(cubeName?: string): Promise<CubeMeta[]> {
const cubes = (cubeName
Expand All @@ -2317,15 +2325,19 @@ export class AnalyticsService implements IAnalyticsService {
return cubes.map(cube => ({
name: cube.name,
title: cube.title,
...(cube.description === undefined ? {} : { description: cube.description }),
measures: Object.entries(cube.measures).map(([key, measure]) => ({
name: `${cube.name}.${key}`,
type: measure.type,
title: measure.label,
...(measure.description === undefined ? {} : { description: measure.description }),
...(measure.format === undefined ? {} : { format: measure.format }),
})),
dimensions: Object.entries(cube.dimensions).map(([key, dimension]) => ({
name: `${cube.name}.${key}`,
type: dimension.type,
title: dimension.label,
...(dimension.description === undefined ? {} : { description: dimension.description }),
})),
}));
}
Expand Down
Loading
Loading