Skip to content

Commit c565813

Browse files
feat(spec,analytics): a dataset answer's measure column states its aggregate, labelled or not (fields[].aggregate) (#22021)
Fixes #21995 Clause-②: yes (widening: a new member on a published response schema; @objectstack/spec changeset at least minor) Dev report for this PR: session `session_01GV6oYwgc1kWiUCb1YaprQ7` (PM loop round 2, `domain:spec` seat 2), branch `claude/issue-21995-measure-column-aggregate`, base `1bc6ca1d`. ## What this changes A dataset answer's measure column now states its aggregate, whether or not the author labelled the measure: - **Spec** (`packages/spec/src/api/analytics.zod.ts`): one new closed member on `AnalyticsResultResponseSchema.data.fields[]`, `aggregate: AggregationFunction.optional()`. The `AnalyticsResult` contract (`packages/spec/src/contracts/analytics-service.ts`) gains the same member, so the compile-time binding between the two (`AnalyticsResultMatchesContract`) holds. - **Producer** (`packages/services/service-analytics/src/analytics-service.ts`, `enrichResultColumns`): one line beside the `builtinAggregate` line, read off the same dataset measure. `enrichResultColumns` runs on both the live query and the draft-data preview, so the two answers agree by construction. - **Where it is absent:** dimension columns; derived measures (guarded with `!m.derived`, because the compiler ignores a stray `aggregate` written beside `derived`); and the cube query answer (`POST /analytics/query`), which never passes through `enrichResultColumns`. The `.describe()` says exactly this. - **Unchanged:** `builtinAggregate` keeps its label-only meaning, and every existing pin on it stays green. No authoring key is added. - **Docs:** `content/docs/api/data-api.mdx` listed "the optional members `AnalyticsResultResponseSchema` declares". That list would be false after this change, so it now names `aggregate` too and says how it differs from `builtinAggregate`. See Acceptance notes: this file is outside the claim's declared surface. - **Changeset:** `.changeset/21995-measure-column-aggregate.md`, with `@objectstack/spec` and `@objectstack/service-analytics` both `minor`. It says what a renderer can now read. What a renderer reads: on `POST /analytics/dataset/query`, a measure column such as `{ name: 'task_count', type: 'number', label: 'Tasks', aggregate: 'count' }`. objectstack-ai/objectui#11681 is the consumer half: once a release carries this, it derives integer ticks from it. ## Mechanism readings (PM hypotheses H1 to H4, measured at `1bc6ca1d`) - **H1, holds with one refinement.** `enrichResultColumns` is the only writer of descriptor keys on a dataset answer's `fields[]`. It has two call sites: the draft preview (`analytics-service.ts:2504`) and the live return (`:2761`). The column ENTRIES themselves are minted upstream as `{ name, type: 'number' }` by the strategies and by `DatasetExecutor` (`dataset-executor.ts:1166` for `__compare`, `:1205` for derived, `:1333`). The degraded "backing object unavailable" exit returns `fields: []`, so it has no column to describe. `queryDataset` has a single implementation repo-wide. The measure's `aggregate` is in hand at the `builtinAggregate` line (`:2866`). - **H2, the schema is shared, and the describe is truthful without a second producer change.** `AnalyticsResultResponseSchema` is the response schema of `POST /analytics/query` (`plugin-rest-api.zod.ts:1324`), and through the `AnalyticsResult` binding it is also the dataset answer's shape. The cube door writes `fields[]` through the strategies, `withDeclaredMeasureFormats` and `withMeasureResultTypes`, and none of them writes `builtinAggregate` or the new member. So the describe states that the member is absent on a cube query answer. A pin calls `AnalyticsService.query()` and asserts that absence. - **H3, holds, with one edge.** A derived measure's column is minted at `dataset-executor.ts:1205` and has no aggregate. The edge: `DatasetSchema` accepts `aggregate` beside `derived`, and the compiler ignores it (`dataset-compiler.ts:704`). So the new member checks `!m.derived` rather than relying on `aggregate` being absent, and a pin covers that case. - **H4, holds.** The REST route ends `res.json(result)` (`rest-server.ts:11319`). No REST source change is needed. `packages/rest/src/analytics-routes.test.ts` passes (14 tests). ## Tests All on HEAD `908f4f02`, or on `91e05bd9` where noted. The commit between them touches only the `.mdx` and the changeset. - `packages/services/service-analytics/src/__tests__/measure-column-aggregate.test.ts` (new, 7 tests, each run on BOTH the live and preview paths): - a labelled `count` measure states `count`, and an unlabelled one still carries `builtinAggregate`; - a `sum` over a currency field states `sum`; - the preview path states the same as the live path, column for column; - absent on a dimension column and on a derived measure; - absent on a derived measure that also declares a stray `aggregate`; - a `__compare` column states its measure's aggregate; - absent on a cube `query()` answer. - `preview-column-enrichment.test.ts`: `aggregate` was added to its key-for-key live/preview parity descriptor. - `packages/spec/src/api/analytics.test.ts`: the member parses beside a `label`. Off-enum `total` is refused at `data.fields.0.aggregate` with issue code `invalid_value`. - `packages/spec/src/contracts/analytics-service.test.ts`: the member types on a labelled column. Off-enum is a compile error (`@ts-expect-error`, compiled by `check:test-typecheck`). - Readings: - spec, 2 files: 42 passed (at `91e05bd9`); - service-analytics full suite: 178 files, 4410 passed, 262 skipped; - rest `analytics-routes.test.ts`: 14 passed; - `typecheck` for `@objectstack/spec` (tsc, scripts-typecheck, test-typecheck) and for `@objectstack/service-analytics`: exit 0. service-analytics resolves `@objectstack/spec` types from `dist/`, so its compiling `f.aggregate` shows it read the rebuilt `.d.ts`. **Reverse verification.** The direction was predicted before running. The fix was committed first. The mutation went through `scripts/ablation-replace.mjs`: the anchor hit 1 time and then 0, the blob changed, and the restore was proven by blob equal to HEAD and an empty `git diff HEAD`. The subject is imported from `src/` (relative import), so no dist leg applies. 1. Delete the producer line. Predicted: positive `aggregate` assertions go red, absences and every `builtinAggregate` assertion stay green. Observed: 4 failed and 47 passed across the 3 files. The red ones were labelled-count, sum, stray-aggregate (its control leg) and `__compare`. 2. Delete only the `!m.derived` guard. Predicted: only the stray-aggregate case goes red. Observed: 1 failed and 50 passed (`live: expected 'sum' to be undefined`). ## Gates These were derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on HEAD `908f4f02`, from a 9-path change set measured against merge base `1bc6ca1d`. - **109 commands derived, 108 run, 0 unrun.** `--ran` reconciliation exits 0. - **107 exited 0 on the first pass.** - `check:api-surface`: "public API surface + factory signatures unchanged". - `check:docs`: "226 generated files in sync". - `check:authorable-surface`: green. - `check-adr-0087-registration`: no declared-breaking changeset. - `check-changeset-no-major`: no major. - `check:nul-bytes`, `check:docs-spec-enumerations`, `check:doc-authoring`, `check:cross-package-test-inputs`, `check:test-source-alias`: OK. - **`pnpm --filter @objectstack/spec run check:skill-examples`** first exited 3 (PREREQUISITE NOT MET, no `client-react` dist). After a turbo build of `@objectstack/client-react` it was re-run and exited 0 ("262 prose examples type-check"). - **NOT MEASURED: `pnpm check:dual-build-cjs-loads`** (exit 3). Reason: it reads every package's built output, and 44 packages had no `dist/` here. A whole-repo build is CI's. - **Run beyond the derived set**, from the PM's lead list (roster families): `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, and spec `check:error-code-provenance`. All exited 0. - **`pnpm --filter @objectstack/spec check:generated`:** all 15 artifacts up to date. The `fields` row in `content/docs/references/api/analytics.mdx` is truncated, so no generated artifact moves. - **ESLint, a declared narrowing.** - Command: `eslint --no-inline-config --format json` over the 7 touched `.ts` files. - Result: 7 files reported, 0 errors, 0 warnings. No "file ignored" warning, so all 7 are inside the config's linted population. - Why the narrowing excludes nothing: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move a verdict on any untouched file. - The `.md` and `.mdx` files are outside ESLint's `files` globs. - Repo-wide `pnpm lint` is CI's. ## Acceptance notes - **File surface.** `content/docs/api/data-api.mdx` is not on the claim's declared surface. It was edited because its sentence naming "the optional members `AnalyticsResultResponseSchema` declares" becomes false with this change. The claim's file list wants that path added. - **`builtinAggregate` on a derived measure with a stray `aggregate`** (noted, not filed). - The producer writes `builtinAggregate` with the stray value on such a column when it is unlabelled. `builtinAggregate`'s own describe says it is absent on derived columns. - `DatasetSchema` accepts `aggregate` beside `derived` at the REST door's parse, and the compiler then ignores it. - No real producer writes that shape: zero co-declarations in `examples/**` and in non-test `packages/**` sources. - Refusing `aggregate` beside `derived` at the schema would close both this and the `!m.derived` guard's reason to exist. That is a contract tightening, outside this card. - **The cube door does not state `aggregate`.** It is declared absent there. A cube measure's `type` is its aggregate, so stating it there would be one write beside `withMeasureResultTypes`. No measured consumer pulls it; the dashboards on this card read dataset answers. - **Branch is behind `origin/main`.** It is 2 commits behind (`1fb274e6`, touching rest/runtime, metadata-protocol, `.claude`). Neither touches a path here, so `main` was not merged. - **Contract review.** The `Clause-②` contract review is owed, as the claim records. --- _Generated by [Claude Code](https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8caa131 commit c565813

9 files changed

Lines changed: 345 additions & 7 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+
feat(spec,analytics): a dataset answer's measure column states its aggregate, labelled or not (`fields[].aggregate`)
7+
8+
Clause-②: yes (widening)
9+
10+
- **What a renderer can now read.** Each measure column of a dataset answer (`POST /analytics/dataset/query`) carries `fields[].aggregate`: the aggregate its dataset measure declares, in the closed `AggregationFunction` vocabulary (`count`, `sum`, `avg`, `min`, `max`, `count_distinct`). It is there whether or not the author gave the measure a `label`. So a chart can tell a count from a sum, for example to draw whole-number axis ticks for a count instead of 0.75 / 1.5 / 2.25.
11+
- **What was missing.** The only aggregate on the wire was `builtinAggregate`, and it is present only when the measure has no `label`. A labelled measure, such as a `count` named "Tasks", reached the wire as `{ name, type: 'number', label }`, with nothing to say what kind of number it was.
12+
- **Where it is set.** `AnalyticsService` writes it in the one step that describes a dataset answer's columns from the dataset's own measures. That step runs for both the live query and the draft-data preview, so the two answers agree. A measure's `__compare` column carries the same aggregate.
13+
- **Where it is absent.** Dimension columns. Derived measures, which combine other measures and have no single aggregate (a stray `aggregate` written beside `derived` is ignored when the dataset compiles, so it is not stated here either). And a cube query answer (`POST /analytics/query`), which does not run through the dataset column step.
14+
- **Unchanged.** `builtinAggregate` keeps its meaning: present only on a label-less measure column, to mark a header that is the server's default. No authoring key is added; `aggregate` is a response member only. `AnalyticsResultResponseSchema` and the `AnalyticsResult` contract declare the member, and the REST route relays it as it does every other column key.

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ is executed. `POST /analytics/sql` refuses the same keys. To order by a member,
461461
<Callout type="info">
462462
**`fields[]` is the resolved presentation surface — read it first.** Each entry carries
463463
`name` and `type` and, when the producer declares them, `label`, `format`, `currency`,
464-
`percentScale` and `builtinAggregate`: the optional members
464+
`percentScale`, `builtinAggregate` and `aggregate`: the optional members
465465
`AnalyticsResultResponseSchema` declares (`packages/spec/src/api/analytics.zod.ts`),
466466
mirrored member for member by `IAnalyticsService.query`'s `AnalyticsResult` and bound to
467467
it at compile time. Optional means a given column may omit them — read them defensively,
@@ -485,6 +485,13 @@ tenant default currency (the `localization.currency` setting), if one is set. A
485485
`dynamic` field (the default mode) never lends its `defaultCurrency` — only `fixed` does —
486486
so its column shows the tenant default. The cube query on this page carries no column
487487
`currency`.
488+
489+
The dataset query also states each measure column's `aggregate` (`count`, `sum`, `avg`,
490+
`min`, `max`, `count_distinct`), whether or not the author labelled the measure, so a
491+
chart can tell a count from a sum. `builtinAggregate` is narrower: it is present only
492+
when the measure has no `label`, to mark a header that is the server's default. Neither
493+
is set on a dimension column or a derived measure, and the cube query on this page
494+
carries neither.
488495
</Callout>
489496

490497
### `GET /analytics/meta`
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `fields[].aggregate` — every measure column of a dataset answer states the
5+
* aggregate its measure declares, whether or not the author labelled it.
6+
*
7+
* `builtinAggregate` answers one question only: "is this header the server's
8+
* default?". It is absent the moment an author writes a `label`, and every
9+
* measured showcase widget labels its measure (a `count` named "Tasks"). So on
10+
* exactly the columns a dashboard draws, the answer said `{ name, type:
11+
* 'number', label }` and nothing else, and a chart over a count drew 0.75 /
12+
* 1.5 / 2.25 axis ticks because it could not tell the count from a sum.
13+
*
14+
* The aggregate is part of the dataset's own authored measure, so the ADR-0021
15+
* column-description seam (`enrichResultColumns`) states it from there, like
16+
* `label` / `format` / `currency` / `percentScale`. That seam serves both paths
17+
* that produce a dataset answer, the live engine query and the ADR-0037 P3
18+
* draft-data preview, so each pin below is asserted on both.
19+
*
20+
* Reverse verification, direction predicted BEFORE running: deleting the one
21+
* producer line that writes `f.aggregate` turns every positive `aggregate`
22+
* assertion RED on both paths and leaves the absence assertions and every
23+
* `builtinAggregate` assertion GREEN. Deleting only its `!m.derived` guard
24+
* turns the stray-aggregate case RED and nothing else.
25+
*/
26+
27+
import { describe, it, expect } from 'vitest';
28+
import { DatasetSchema } from '@objectstack/spec/ui';
29+
import type { ExecutionContext } from '@objectstack/spec/kernel';
30+
import { AnalyticsService } from '../analytics-service.js';
31+
32+
interface Task extends Record<string, unknown> {
33+
status: string;
34+
amount: number;
35+
due_on: string;
36+
}
37+
38+
const ROWS: Task[] = [
39+
{ status: 'open', amount: 100, due_on: '2026-02-03' },
40+
{ status: 'open', amount: 50, due_on: '2026-02-10' },
41+
{ status: 'done', amount: 25, due_on: '2026-02-15' },
42+
{ status: 'done', amount: 10, due_on: '2026-01-20' },
43+
];
44+
45+
const DATASET = DatasetSchema.parse({
46+
name: 'task_ds',
47+
label: 'Tasks',
48+
object: 'task',
49+
dimensions: [
50+
{ name: 'status', field: 'status', type: 'string', label: 'Status' },
51+
{ name: 'due_on', field: 'due_on', type: 'date', label: 'Due' },
52+
],
53+
measures: [
54+
// The showcase shape: a `count` the author named.
55+
{ name: 'task_count', aggregate: 'count', label: 'Tasks' },
56+
// The built-in default: no label, so `builtinAggregate` too.
57+
{ name: 'count', aggregate: 'count' },
58+
// A `sum` over a currency field.
59+
{ name: 'total_amount', aggregate: 'sum', field: 'amount', label: 'Total Amount', format: '$0,0' },
60+
// A derived measure has no single aggregate.
61+
{ name: 'open_share', derived: { op: 'ratio', of: ['task_count', 'count'] }, label: 'Share' },
62+
],
63+
});
64+
65+
const sourceFieldMeta = (object: string, field: string) => {
66+
if (object !== 'task') return undefined;
67+
if (field === 'amount') return { type: 'currency' };
68+
if (field === 'due_on') return { type: 'date' };
69+
return undefined;
70+
};
71+
72+
const CTX = { tenantId: 'org_A', currency: 'USD' } as ExecutionContext;
73+
74+
/** Enough of a GROUP BY for this fixture: the LIVE path's engine. */
75+
function evaluateAggregate(opts: { groupBy?: unknown; aggregations?: unknown }) {
76+
const groupBy = (opts.groupBy ?? []) as Array<string | { field: string }>;
77+
const aggs = (opts.aggregations ?? []) as Array<{ field: string; method: string; alias: string }>;
78+
const buckets = new Map<string, { key: Record<string, unknown>; rows: Task[] }>();
79+
for (const r of ROWS) {
80+
const key: Record<string, unknown> = {};
81+
for (const g of groupBy) {
82+
const f = typeof g === 'string' ? g : g.field;
83+
key[f] = r[f];
84+
}
85+
const id = JSON.stringify(Object.values(key));
86+
let b = buckets.get(id);
87+
if (!b) { b = { key, rows: [] }; buckets.set(id, b); }
88+
b.rows.push(r);
89+
}
90+
return [...buckets.values()].map(({ key, rows }) => {
91+
const row: Record<string, unknown> = { ...key };
92+
for (const a of aggs) {
93+
row[a.alias] = a.method === 'sum'
94+
? rows.reduce((s, r) => s + Number(r[a.field] ?? 0), 0)
95+
: rows.length;
96+
}
97+
return row;
98+
});
99+
}
100+
101+
/** Two services that differ ONLY in whether a pending seed draft exists. */
102+
function svc(preview: boolean) {
103+
return new AnalyticsService({
104+
sourceFieldMeta,
105+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
106+
executeAggregate: async (_object: string, options: Record<string, unknown>) => evaluateAggregate(options),
107+
...(preview ? { draftRowsResolver: async () => ROWS as Record<string, unknown>[] } : {}),
108+
});
109+
}
110+
111+
type Field = Awaited<ReturnType<AnalyticsService['queryDataset']>>['fields'][number];
112+
const byName = (fields: Field[]) => Object.fromEntries(fields.map((f) => [f.name, f]));
113+
114+
async function bothPaths(dataset = DATASET, selection: Record<string, unknown> = {
115+
dimensions: ['status'],
116+
measures: ['task_count', 'count', 'total_amount', 'open_share'],
117+
}) {
118+
const live = await svc(false).queryDataset(dataset, selection as never, CTX);
119+
const preview = await svc(true).queryDataset(dataset, selection as never, CTX, { previewDrafts: true });
120+
return { live: byName(live.fields), preview: byName(preview.fields) };
121+
}
122+
123+
describe('fields[].aggregate — a dataset answer states each measure column\'s aggregate', () => {
124+
it('a labelled `count` measure states `count`, and an unlabelled one still carries builtinAggregate', async () => {
125+
const { live, preview } = await bothPaths();
126+
for (const [path, fields] of [['live', live], ['preview', preview]] as const) {
127+
// The showcase column: the author's label, and now the aggregate beside it.
128+
expect(fields.task_count?.label, path).toBe('Tasks');
129+
expect(fields.task_count?.aggregate, path).toBe('count');
130+
// `builtinAggregate` keeps its label-only meaning: absent under a label…
131+
expect(fields.task_count?.builtinAggregate, path).toBeUndefined();
132+
// …and present, beside the new member, on the label-less default.
133+
expect(fields.count?.builtinAggregate, path).toBe('count');
134+
expect(fields.count?.aggregate, path).toBe('count');
135+
expect(fields.count?.label, path).toBeUndefined();
136+
}
137+
});
138+
139+
it('a `sum` over a currency field states `sum`', async () => {
140+
const { live, preview } = await bothPaths();
141+
for (const [path, fields] of [['live', live], ['preview', preview]] as const) {
142+
expect(fields.total_amount?.aggregate, path).toBe('sum');
143+
// The column's other descriptors are untouched by the new member.
144+
expect(fields.total_amount?.currency, path).toBe('USD');
145+
expect(fields.total_amount?.format, path).toBe('$0,0');
146+
expect(fields.total_amount?.type, path).toBe('number');
147+
}
148+
});
149+
150+
it('the preview path states the same as the live path, column for column', async () => {
151+
const { live, preview } = await bothPaths();
152+
expect(Object.keys(preview).sort()).toEqual(Object.keys(live).sort());
153+
for (const name of Object.keys(live)) {
154+
expect(preview[name]?.aggregate, `column "${name}"`).toBe(live[name]?.aggregate);
155+
}
156+
});
157+
158+
it('is absent on a dimension column and on a derived measure, on both paths', async () => {
159+
const { live, preview } = await bothPaths();
160+
for (const [path, fields] of [['live', live], ['preview', preview]] as const) {
161+
expect(fields.status, path).toBeDefined();
162+
expect(fields.status?.aggregate, path).toBeUndefined();
163+
expect(fields.open_share, path).toBeDefined();
164+
expect(fields.open_share?.aggregate, path).toBeUndefined();
165+
}
166+
});
167+
168+
it('a derived measure that also declares a stray `aggregate` states none: the compiler ignores it', async () => {
169+
const stray = DatasetSchema.parse({
170+
...DATASET,
171+
name: 'task_ds_stray',
172+
measures: [
173+
{ name: 'task_count', aggregate: 'count', label: 'Tasks' },
174+
{ name: 'count', aggregate: 'count' },
175+
{ name: 'open_share', derived: { op: 'ratio', of: ['task_count', 'count'] }, aggregate: 'sum', label: 'Share' },
176+
],
177+
});
178+
const { live, preview } = await bothPaths(stray, { dimensions: ['status'], measures: ['task_count', 'count', 'open_share'] });
179+
for (const [path, fields] of [['live', live], ['preview', preview]] as const) {
180+
expect(fields.open_share, path).toBeDefined();
181+
expect(fields.open_share?.aggregate, path).toBeUndefined();
182+
// Control: the base measures beside it are still described.
183+
expect(fields.task_count?.aggregate, path).toBe('count');
184+
}
185+
});
186+
187+
it('a `__compare` column states its measure\'s aggregate, on both paths', async () => {
188+
const selection = {
189+
dimensions: ['status'],
190+
measures: ['task_count', 'total_amount'],
191+
timeDimensions: [{ dimension: 'due_on', dateRange: ['2026-02-01', '2026-02-28'] }],
192+
compareTo: { kind: 'previousPeriod', dimension: 'due_on' },
193+
};
194+
const { live, preview } = await bothPaths(DATASET, selection);
195+
for (const [path, fields] of [['live', live], ['preview', preview]] as const) {
196+
expect(fields.task_count__compare, path).toBeDefined();
197+
expect(fields.task_count__compare?.aggregate, path).toBe('count');
198+
expect(fields.total_amount__compare?.aggregate, path).toBe('sum');
199+
}
200+
});
201+
});
202+
203+
describe('fields[].aggregate — absent on a cube query answer', () => {
204+
it('`query()` (POST /analytics/query) never passes through the dataset column seam, so it states none', async () => {
205+
const service = new AnalyticsService({
206+
cubes: [{
207+
name: 'task_cube',
208+
title: 'Tasks',
209+
sql: 'task',
210+
measures: { task_count: { label: 'Tasks', type: 'count', sql: '*' } },
211+
dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } },
212+
} as never],
213+
sourceFieldMeta,
214+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
215+
executeAggregate: async (_object: string, options: Record<string, unknown>) => evaluateAggregate(options),
216+
});
217+
const result = await service.query(
218+
{ cube: 'task_cube', measures: ['task_count'], dimensions: ['status'] },
219+
CTX,
220+
);
221+
const measure = result.fields.find((f) => f.name === 'task_count');
222+
expect(measure).toBeDefined();
223+
expect(measure?.aggregate).toBeUndefined();
224+
});
225+
});

‎packages/services/service-analytics/src/__tests__/preview-column-enrichment.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
* | `label` | `measure.label` / `dimension.label` + `ctx.locale` (#6761) |
4949
* | `format` | `measure.format` |
5050
* | `builtinAggregate` | `measure.aggregate` + `measure.label == null` (#14492) |
51+
* | `aggregate` | `measure.aggregate`, labelled or not; never on a `derived` measure |
5152
* | `currency` | `measure.currency` → the source field's FIXED currency (`sourceFieldMeta().defaultCurrency`, relayed only under `currencyMode: 'fixed'`) → `ctx.currency` |
5253
* | `percentScale` | `measure.derived.op === 'ratio'`, else `percentScaleOf(sourceFieldMeta())` (objectui#3136) |
5354
* | `type` | `measureResultType(measure.aggregate, sourceFieldMeta().type)` (#16101) |
@@ -238,10 +239,10 @@ async function bothPaths() {
238239
return { live: by(live.fields), preview: by(preview.fields) };
239240
}
240241

241-
/** The six keys the card tabulates, absent ones dropped so they read as absent. */
242+
/** The keys the table above lists, absent ones dropped so they read as absent. */
242243
function descriptor(f: Field | undefined): Record<string, unknown> {
243244
const out: Record<string, unknown> = {};
244-
for (const k of ['label', 'format', 'currency', 'percentScale', 'builtinAggregate', 'type'] as const) {
245+
for (const k of ['label', 'format', 'currency', 'percentScale', 'builtinAggregate', 'aggregate', 'type'] as const) {
245246
const v = (f as Record<string, unknown> | undefined)?.[k];
246247
if (v != null) out[k] = v;
247248
}

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2492,8 +2492,8 @@ export class AnalyticsService implements IAnalyticsService {
24922492
const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
24932493
// ADR-0021 result-column enrichment runs on this path too. Every key it
24942494
// writes describes the dataset's OWN authored columns — a measure's
2495-
// `label` / `format` / `currency` / `percentScale` / `builtinAggregate`
2496-
// and the `type` its aggregate really returns, plus a dimension column's
2495+
// `label` / `format` / `currency` / `percentScale` / `builtinAggregate` /
2496+
// `aggregate` and the `type` its aggregate really returns, plus a dimension column's
24972497
// header `label` — all read off the dataset definition and
24982498
// `sourceFieldMeta`, never off the rows. #16097: this early `return`
24992499
// used to sit ~250 lines ahead of that block, so the same dataset in the
@@ -2777,8 +2777,8 @@ export class AnalyticsService implements IAnalyticsService {
27772777
/**
27782778
* ADR-0021 — describe the result's COLUMNS from the dataset's own authored
27792779
* definition: a measure's `label` / `format` / `currency` / `percentScale` /
2780-
* `builtinAggregate` and the `type` its aggregate really returns, then a
2781-
* dimension column's header `label`.
2780+
* `builtinAggregate` / `aggregate` and the `type` its aggregate really
2781+
* returns, then a dimension column's header `label`.
27822782
*
27832783
* **Every key here is read off the DATASET** (the authored measure or
27842784
* dimension) **and `sourceFieldMeta`** (the source object's declared field
@@ -2864,6 +2864,16 @@ export class AnalyticsService implements IAnalyticsService {
28642864
// #14492: it would catch an author who really named a field `Count`,
28652865
// and break the moment the default is spelled in another language.
28662866
if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
2867+
// The aggregate itself, stated whatever the header says. The
2868+
// discriminator above answers only "is this header the server's
2869+
// default?", so a LABELLED `count` ("Tasks") reached the wire as a bare
2870+
// `type: 'number'` and a chart could not tell it from a `sum`: it drew
2871+
// 0.75 / 1.5 / 2.25 ticks on a count axis. Read off the authored
2872+
// measure like every other key here, so the live and preview paths
2873+
// agree by construction. ⛔ Not on a derived measure: the compiler
2874+
// ignores a `derived` measure's stray `aggregate` and computes it from
2875+
// its `of` measures, so that `aggregate` describes nothing on the wire.
2876+
if (f.aggregate == null && !m.derived && m.aggregate) f.aggregate = m.aggregate;
28672877
if (f.format == null && m.format) f.format = m.format;
28682878
// Currency chain. A MONETARY measure resolves its display currency
28692879
// from: explicit measure `currency` → the source field's FIXED

0 commit comments

Comments
 (0)