diff --git a/.changeset/21995-measure-column-aggregate.md b/.changeset/21995-measure-column-aggregate.md
new file mode 100644
index 00000000000..06b997295f9
--- /dev/null
+++ b/.changeset/21995-measure-column-aggregate.md
@@ -0,0 +1,14 @@
+---
+"@objectstack/spec": minor
+"@objectstack/service-analytics": minor
+---
+
+feat(spec,analytics): a dataset answer's measure column states its aggregate, labelled or not (`fields[].aggregate`)
+
+Clause-②: yes (widening)
+
+- **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.
+- **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.
+- **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.
+- **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.
+- **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.
diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx
index af2b77c4495..45316d60432 100644
--- a/content/docs/api/data-api.mdx
+++ b/content/docs/api/data-api.mdx
@@ -461,7 +461,7 @@ is executed. `POST /analytics/sql` refuses the same keys. To order by a member,
**`fields[]` is the resolved presentation surface — read it first.** Each entry carries
`name` and `type` and, when the producer declares them, `label`, `format`, `currency`,
-`percentScale` and `builtinAggregate`: the optional members
+`percentScale`, `builtinAggregate` and `aggregate`: the optional members
`AnalyticsResultResponseSchema` declares (`packages/spec/src/api/analytics.zod.ts`),
mirrored member for member by `IAnalyticsService.query`'s `AnalyticsResult` and bound to
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
`dynamic` field (the default mode) never lends its `defaultCurrency` — only `fixed` does —
so its column shows the tenant default. The cube query on this page carries no column
`currency`.
+
+The dataset query also states each measure column's `aggregate` (`count`, `sum`, `avg`,
+`min`, `max`, `count_distinct`), whether or not the author labelled the measure, so a
+chart can tell a count from a sum. `builtinAggregate` is narrower: it is present only
+when the measure has no `label`, to mark a header that is the server's default. Neither
+is set on a dimension column or a derived measure, and the cube query on this page
+carries neither.
### `GET /analytics/meta`
diff --git a/packages/services/service-analytics/src/__tests__/measure-column-aggregate.test.ts b/packages/services/service-analytics/src/__tests__/measure-column-aggregate.test.ts
new file mode 100644
index 00000000000..d082945be26
--- /dev/null
+++ b/packages/services/service-analytics/src/__tests__/measure-column-aggregate.test.ts
@@ -0,0 +1,225 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `fields[].aggregate` — every measure column of a dataset answer states the
+ * aggregate its measure declares, whether or not the author labelled it.
+ *
+ * `builtinAggregate` answers one question only: "is this header the server's
+ * default?". It is absent the moment an author writes a `label`, and every
+ * measured showcase widget labels its measure (a `count` named "Tasks"). So on
+ * exactly the columns a dashboard draws, the answer said `{ name, type:
+ * 'number', label }` and nothing else, and a chart over a count drew 0.75 /
+ * 1.5 / 2.25 axis ticks because it could not tell the count from a sum.
+ *
+ * The aggregate is part of the dataset's own authored measure, so the ADR-0021
+ * column-description seam (`enrichResultColumns`) states it from there, like
+ * `label` / `format` / `currency` / `percentScale`. That seam serves both paths
+ * that produce a dataset answer, the live engine query and the ADR-0037 P3
+ * draft-data preview, so each pin below is asserted on both.
+ *
+ * Reverse verification, direction predicted BEFORE running: deleting the one
+ * producer line that writes `f.aggregate` turns every positive `aggregate`
+ * assertion RED on both paths and leaves the absence assertions and every
+ * `builtinAggregate` assertion GREEN. Deleting only its `!m.derived` guard
+ * turns the stray-aggregate case RED and nothing else.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { DatasetSchema } from '@objectstack/spec/ui';
+import type { ExecutionContext } from '@objectstack/spec/kernel';
+import { AnalyticsService } from '../analytics-service.js';
+
+interface Task extends Record {
+ status: string;
+ amount: number;
+ due_on: string;
+}
+
+const ROWS: Task[] = [
+ { status: 'open', amount: 100, due_on: '2026-02-03' },
+ { status: 'open', amount: 50, due_on: '2026-02-10' },
+ { status: 'done', amount: 25, due_on: '2026-02-15' },
+ { status: 'done', amount: 10, due_on: '2026-01-20' },
+];
+
+const DATASET = DatasetSchema.parse({
+ name: 'task_ds',
+ label: 'Tasks',
+ object: 'task',
+ dimensions: [
+ { name: 'status', field: 'status', type: 'string', label: 'Status' },
+ { name: 'due_on', field: 'due_on', type: 'date', label: 'Due' },
+ ],
+ measures: [
+ // The showcase shape: a `count` the author named.
+ { name: 'task_count', aggregate: 'count', label: 'Tasks' },
+ // The built-in default: no label, so `builtinAggregate` too.
+ { name: 'count', aggregate: 'count' },
+ // A `sum` over a currency field.
+ { name: 'total_amount', aggregate: 'sum', field: 'amount', label: 'Total Amount', format: '$0,0' },
+ // A derived measure has no single aggregate.
+ { name: 'open_share', derived: { op: 'ratio', of: ['task_count', 'count'] }, label: 'Share' },
+ ],
+});
+
+const sourceFieldMeta = (object: string, field: string) => {
+ if (object !== 'task') return undefined;
+ if (field === 'amount') return { type: 'currency' };
+ if (field === 'due_on') return { type: 'date' };
+ return undefined;
+};
+
+const CTX = { tenantId: 'org_A', currency: 'USD' } as ExecutionContext;
+
+/** Enough of a GROUP BY for this fixture: the LIVE path's engine. */
+function evaluateAggregate(opts: { groupBy?: unknown; aggregations?: unknown }) {
+ const groupBy = (opts.groupBy ?? []) as Array;
+ const aggs = (opts.aggregations ?? []) as Array<{ field: string; method: string; alias: string }>;
+ const buckets = new Map; rows: Task[] }>();
+ for (const r of ROWS) {
+ const key: Record = {};
+ for (const g of groupBy) {
+ const f = typeof g === 'string' ? g : g.field;
+ key[f] = r[f];
+ }
+ const id = JSON.stringify(Object.values(key));
+ let b = buckets.get(id);
+ if (!b) { b = { key, rows: [] }; buckets.set(id, b); }
+ b.rows.push(r);
+ }
+ return [...buckets.values()].map(({ key, rows }) => {
+ const row: Record = { ...key };
+ for (const a of aggs) {
+ row[a.alias] = a.method === 'sum'
+ ? rows.reduce((s, r) => s + Number(r[a.field] ?? 0), 0)
+ : rows.length;
+ }
+ return row;
+ });
+}
+
+/** Two services that differ ONLY in whether a pending seed draft exists. */
+function svc(preview: boolean) {
+ return new AnalyticsService({
+ sourceFieldMeta,
+ queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
+ executeAggregate: async (_object: string, options: Record) => evaluateAggregate(options),
+ ...(preview ? { draftRowsResolver: async () => ROWS as Record[] } : {}),
+ });
+}
+
+type Field = Awaited>['fields'][number];
+const byName = (fields: Field[]) => Object.fromEntries(fields.map((f) => [f.name, f]));
+
+async function bothPaths(dataset = DATASET, selection: Record = {
+ dimensions: ['status'],
+ measures: ['task_count', 'count', 'total_amount', 'open_share'],
+}) {
+ const live = await svc(false).queryDataset(dataset, selection as never, CTX);
+ const preview = await svc(true).queryDataset(dataset, selection as never, CTX, { previewDrafts: true });
+ return { live: byName(live.fields), preview: byName(preview.fields) };
+}
+
+describe('fields[].aggregate — a dataset answer states each measure column\'s aggregate', () => {
+ it('a labelled `count` measure states `count`, and an unlabelled one still carries builtinAggregate', async () => {
+ const { live, preview } = await bothPaths();
+ for (const [path, fields] of [['live', live], ['preview', preview]] as const) {
+ // The showcase column: the author's label, and now the aggregate beside it.
+ expect(fields.task_count?.label, path).toBe('Tasks');
+ expect(fields.task_count?.aggregate, path).toBe('count');
+ // `builtinAggregate` keeps its label-only meaning: absent under a label…
+ expect(fields.task_count?.builtinAggregate, path).toBeUndefined();
+ // …and present, beside the new member, on the label-less default.
+ expect(fields.count?.builtinAggregate, path).toBe('count');
+ expect(fields.count?.aggregate, path).toBe('count');
+ expect(fields.count?.label, path).toBeUndefined();
+ }
+ });
+
+ it('a `sum` over a currency field states `sum`', async () => {
+ const { live, preview } = await bothPaths();
+ for (const [path, fields] of [['live', live], ['preview', preview]] as const) {
+ expect(fields.total_amount?.aggregate, path).toBe('sum');
+ // The column's other descriptors are untouched by the new member.
+ expect(fields.total_amount?.currency, path).toBe('USD');
+ expect(fields.total_amount?.format, path).toBe('$0,0');
+ expect(fields.total_amount?.type, path).toBe('number');
+ }
+ });
+
+ it('the preview path states the same as the live path, column for column', async () => {
+ const { live, preview } = await bothPaths();
+ expect(Object.keys(preview).sort()).toEqual(Object.keys(live).sort());
+ for (const name of Object.keys(live)) {
+ expect(preview[name]?.aggregate, `column "${name}"`).toBe(live[name]?.aggregate);
+ }
+ });
+
+ it('is absent on a dimension column and on a derived measure, on both paths', async () => {
+ const { live, preview } = await bothPaths();
+ for (const [path, fields] of [['live', live], ['preview', preview]] as const) {
+ expect(fields.status, path).toBeDefined();
+ expect(fields.status?.aggregate, path).toBeUndefined();
+ expect(fields.open_share, path).toBeDefined();
+ expect(fields.open_share?.aggregate, path).toBeUndefined();
+ }
+ });
+
+ it('a derived measure that also declares a stray `aggregate` states none: the compiler ignores it', async () => {
+ const stray = DatasetSchema.parse({
+ ...DATASET,
+ name: 'task_ds_stray',
+ measures: [
+ { name: 'task_count', aggregate: 'count', label: 'Tasks' },
+ { name: 'count', aggregate: 'count' },
+ { name: 'open_share', derived: { op: 'ratio', of: ['task_count', 'count'] }, aggregate: 'sum', label: 'Share' },
+ ],
+ });
+ const { live, preview } = await bothPaths(stray, { dimensions: ['status'], measures: ['task_count', 'count', 'open_share'] });
+ for (const [path, fields] of [['live', live], ['preview', preview]] as const) {
+ expect(fields.open_share, path).toBeDefined();
+ expect(fields.open_share?.aggregate, path).toBeUndefined();
+ // Control: the base measures beside it are still described.
+ expect(fields.task_count?.aggregate, path).toBe('count');
+ }
+ });
+
+ it('a `__compare` column states its measure\'s aggregate, on both paths', async () => {
+ const selection = {
+ dimensions: ['status'],
+ measures: ['task_count', 'total_amount'],
+ timeDimensions: [{ dimension: 'due_on', dateRange: ['2026-02-01', '2026-02-28'] }],
+ compareTo: { kind: 'previousPeriod', dimension: 'due_on' },
+ };
+ const { live, preview } = await bothPaths(DATASET, selection);
+ for (const [path, fields] of [['live', live], ['preview', preview]] as const) {
+ expect(fields.task_count__compare, path).toBeDefined();
+ expect(fields.task_count__compare?.aggregate, path).toBe('count');
+ expect(fields.total_amount__compare?.aggregate, path).toBe('sum');
+ }
+ });
+});
+
+describe('fields[].aggregate — absent on a cube query answer', () => {
+ it('`query()` (POST /analytics/query) never passes through the dataset column seam, so it states none', async () => {
+ const service = new AnalyticsService({
+ cubes: [{
+ name: 'task_cube',
+ title: 'Tasks',
+ sql: 'task',
+ measures: { task_count: { label: 'Tasks', type: 'count', sql: '*' } },
+ dimensions: { status: { label: 'Status', type: 'string', sql: 'status' } },
+ } as never],
+ sourceFieldMeta,
+ queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
+ executeAggregate: async (_object: string, options: Record) => evaluateAggregate(options),
+ });
+ const result = await service.query(
+ { cube: 'task_cube', measures: ['task_count'], dimensions: ['status'] },
+ CTX,
+ );
+ const measure = result.fields.find((f) => f.name === 'task_count');
+ expect(measure).toBeDefined();
+ expect(measure?.aggregate).toBeUndefined();
+ });
+});
diff --git a/packages/services/service-analytics/src/__tests__/preview-column-enrichment.test.ts b/packages/services/service-analytics/src/__tests__/preview-column-enrichment.test.ts
index c8253cec3c8..e66dd468e7f 100644
--- a/packages/services/service-analytics/src/__tests__/preview-column-enrichment.test.ts
+++ b/packages/services/service-analytics/src/__tests__/preview-column-enrichment.test.ts
@@ -48,6 +48,7 @@
* | `label` | `measure.label` / `dimension.label` + `ctx.locale` (#6761) |
* | `format` | `measure.format` |
* | `builtinAggregate` | `measure.aggregate` + `measure.label == null` (#14492) |
+ * | `aggregate` | `measure.aggregate`, labelled or not; never on a `derived` measure |
* | `currency` | `measure.currency` → the source field's FIXED currency (`sourceFieldMeta().defaultCurrency`, relayed only under `currencyMode: 'fixed'`) → `ctx.currency` |
* | `percentScale` | `measure.derived.op === 'ratio'`, else `percentScaleOf(sourceFieldMeta())` (objectui#3136) |
* | `type` | `measureResultType(measure.aggregate, sourceFieldMeta().type)` (#16101) |
@@ -238,10 +239,10 @@ async function bothPaths() {
return { live: by(live.fields), preview: by(preview.fields) };
}
-/** The six keys the card tabulates, absent ones dropped so they read as absent. */
+/** The keys the table above lists, absent ones dropped so they read as absent. */
function descriptor(f: Field | undefined): Record {
const out: Record = {};
- for (const k of ['label', 'format', 'currency', 'percentScale', 'builtinAggregate', 'type'] as const) {
+ for (const k of ['label', 'format', 'currency', 'percentScale', 'builtinAggregate', 'aggregate', 'type'] as const) {
const v = (f as Record | undefined)?.[k];
if (v != null) out[k] = v;
}
diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts
index 190f6cb1352..4388292f931 100644
--- a/packages/services/service-analytics/src/analytics-service.ts
+++ b/packages/services/service-analytics/src/analytics-service.ts
@@ -2492,8 +2492,8 @@ export class AnalyticsService implements IAnalyticsService {
const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
// ADR-0021 result-column enrichment runs on this path too. Every key it
// writes describes the dataset's OWN authored columns — a measure's
- // `label` / `format` / `currency` / `percentScale` / `builtinAggregate`
- // and the `type` its aggregate really returns, plus a dimension column's
+ // `label` / `format` / `currency` / `percentScale` / `builtinAggregate` /
+ // `aggregate` and the `type` its aggregate really returns, plus a dimension column's
// header `label` — all read off the dataset definition and
// `sourceFieldMeta`, never off the rows. #16097: this early `return`
// used to sit ~250 lines ahead of that block, so the same dataset in the
@@ -2777,8 +2777,8 @@ export class AnalyticsService implements IAnalyticsService {
/**
* ADR-0021 — describe the result's COLUMNS from the dataset's own authored
* definition: a measure's `label` / `format` / `currency` / `percentScale` /
- * `builtinAggregate` and the `type` its aggregate really returns, then a
- * dimension column's header `label`.
+ * `builtinAggregate` / `aggregate` and the `type` its aggregate really
+ * returns, then a dimension column's header `label`.
*
* **Every key here is read off the DATASET** (the authored measure or
* dimension) **and `sourceFieldMeta`** (the source object's declared field
@@ -2864,6 +2864,16 @@ export class AnalyticsService implements IAnalyticsService {
// #14492: it would catch an author who really named a field `Count`,
// and break the moment the default is spelled in another language.
if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
+ // The aggregate itself, stated whatever the header says. The
+ // discriminator above answers only "is this header the server's
+ // default?", so a LABELLED `count` ("Tasks") reached the wire as a bare
+ // `type: 'number'` and a chart could not tell it from a `sum`: it drew
+ // 0.75 / 1.5 / 2.25 ticks on a count axis. Read off the authored
+ // measure like every other key here, so the live and preview paths
+ // agree by construction. ⛔ Not on a derived measure: the compiler
+ // ignores a `derived` measure's stray `aggregate` and computes it from
+ // its `of` measures, so that `aggregate` describes nothing on the wire.
+ if (f.aggregate == null && !m.derived && m.aggregate) f.aggregate = m.aggregate;
if (f.format == null && m.format) f.format = m.format;
// Currency chain. A MONETARY measure resolves its display currency
// from: explicit measure `currency` → the source field's FIXED
diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts
index 7bd4b917a44..7b1e18a2c43 100644
--- a/packages/spec/src/api/analytics.test.ts
+++ b/packages/spec/src/api/analytics.test.ts
@@ -230,6 +230,39 @@ describe('AnalyticsResultResponseSchema', () => {
expect(bad.success ? [] : bad.error.issues.map((i) => i.path.join('.'))).toContain('data.fields.0.builtinAggregate');
});
+ // `fields[].aggregate` — the measure's aggregate, stated whether or not the
+ // author labelled the column, so it rides beside a `label` where
+ // `builtinAggregate` never does. Same closed `AggregationFunction`
+ // vocabulary: a spelling outside it is refused at the member, not stripped.
+ it('should preserve fields[].aggregate beside a label and refuse a spelling outside the closed enum', () => {
+ const resp = AnalyticsResultResponseSchema.parse({
+ success: true,
+ data: {
+ rows: [{ status: 'open', task_count: 7, count: 7 }],
+ fields: [
+ { name: 'status', type: 'string', label: 'Status' },
+ { name: 'task_count', type: 'number', label: 'Tasks', aggregate: 'count' },
+ { name: 'count', type: 'number', builtinAggregate: 'count', aggregate: 'count' },
+ ],
+ },
+ });
+ expect(resp.data.fields[1].aggregate).toBe('count');
+ expect(resp.data.fields[1].label).toBe('Tasks');
+ expect(resp.data.fields[1].builtinAggregate).toBeUndefined();
+ expect(resp.data.fields[2].aggregate).toBe('count');
+ expect(resp.data.fields[2].builtinAggregate).toBe('count');
+ expect(resp.data.fields[0].aggregate).toBeUndefined();
+
+ const bad = AnalyticsResultResponseSchema.safeParse({
+ success: true,
+ data: { rows: [], fields: [{ name: 'task_count', type: 'number', label: 'Tasks', aggregate: 'total' }] },
+ });
+ expect(bad.success).toBe(false);
+ const issues = bad.success ? [] : bad.error.issues;
+ expect(issues.map((i) => i.path.join('.'))).toContain('data.fields.0.aggregate');
+ expect(issues.find((i) => i.path.join('.') === 'data.fields.0.aggregate')?.code).toBe('invalid_value');
+ });
+
it('should preserve totals — the marginal-aggregate channel, grand total included', () => {
const resp = AnalyticsResultResponseSchema.parse({
success: true,
diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts
index 637b411ef84..e152cbf583c 100644
--- a/packages/spec/src/api/analytics.zod.ts
+++ b/packages/spec/src/api/analytics.zod.ts
@@ -129,6 +129,17 @@ export const AnalyticsResultResponseSchema = lazySchema(() => BaseResponseSchema
+ 'name for the aggregate. Absent whenever the author declared a label, and '
+ 'on dimension / derived columns.',
),
+ aggregate: AggregationFunction.optional().describe(
+ 'The aggregate a measure column carries, in the closed `AggregationFunction` '
+ + 'vocabulary: the dataset measure\'s own `aggregate`, stated whether or not the '
+ + 'author declared a `label`, so a renderer can tell a `count` from a `sum` (integer '
+ + 'axis ticks for a count, for example). Set on every measure column of a dataset '
+ + 'answer (`POST /analytics/dataset/query`, the live query and the draft-data '
+ + 'preview alike) whose measure declares an `aggregate`, its `__compare` column '
+ + 'included. Absent on dimension columns and on derived measures, which have no '
+ + 'single aggregate, and on a cube query answer (`POST /analytics/query`). Unlike '
+ + '`builtinAggregate`, it says nothing about who named the column.',
+ ),
})).describe('Column metadata'),
sql: z.string().optional().describe('Executed SQL (if debug enabled)'),
totals: z.array(z.object({
diff --git a/packages/spec/src/contracts/analytics-service.test.ts b/packages/spec/src/contracts/analytics-service.test.ts
index 4abc11d74a6..31154e6d0a8 100644
--- a/packages/spec/src/contracts/analytics-service.test.ts
+++ b/packages/spec/src/contracts/analytics-service.test.ts
@@ -88,6 +88,23 @@ describe('Analytics Service Contract', () => {
expect(offEnum.name).toBe('count');
});
+ // `fields[].aggregate` is optional and is the closed `AggregationFunction`
+ // vocabulary. Unlike `builtinAggregate` it rides beside an authored label:
+ // the first literal is the labelled `count` column the producer now emits;
+ // the second pins the closure at compile time.
+ it('carries aggregate on a labelled measure column and refuses a spelling outside the enum', () => {
+ const labelled: AnalyticsResult['fields'][number] = { name: 'task_count', type: 'number', label: 'Tasks', aggregate: 'count' };
+ expect(labelled.aggregate).toBe('count');
+ expect(labelled.builtinAggregate).toBeUndefined();
+ const offEnum: AnalyticsResult['fields'][number] = {
+ name: 'task_count',
+ type: 'number',
+ // @ts-expect-error — `total` is not an AggregationFunction; the member is closed
+ aggregate: 'total',
+ };
+ expect(offEnum.name).toBe('task_count');
+ });
+
// `object` — the dataset's base object, declared on the answer itself and not
// on a drill-through side type: a `queryDataset` implementation returns it on
// a dimension-less, zero-row answer against the plain `AnalyticsResult`, and
diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts
index ee792541cce..0d0d89ac5ae 100644
--- a/packages/spec/src/contracts/analytics-service.ts
+++ b/packages/spec/src/contracts/analytics-service.ts
@@ -116,6 +116,26 @@ export interface AnalyticsResult {
* aggregate vocabulary; no second spelling.
*/
builtinAggregate?: AggregationFunction;
+ /**
+ * The aggregate the measure column carries — the dataset measure's own
+ * `aggregate`, stated whether or not the author declared a `label`.
+ * `builtinAggregate` answers "is this header the server's default?";
+ * this answers "what kind of number is this?", which a renderer needs
+ * whatever the header says: a `count` is a whole number, so a chart
+ * axis over it takes integer ticks, while a `sum` or `avg` may be
+ * fractional.
+ *
+ * Present on every measure column of a `queryDataset` answer — the live
+ * query and the draft-data preview, which share one column-description
+ * seam — whose dataset measure declares an `aggregate`, the measure's
+ * `__compare` column included (the same aggregate over the shifted
+ * window). Absent on dimension columns and on derived measures, which
+ * combine other measures and have no single aggregate (a `derived`
+ * measure's stray `aggregate` is ignored at compile time and is not
+ * stated here either), and absent on a `query` (cube) answer. Reuses
+ * `AggregationFunction`, the one closed aggregate vocabulary.
+ */
+ aggregate?: AggregationFunction;
}>;
/** Generated SQL (if available) */
sql?: string;