Skip to content

Commit 03062f3

Browse files
committed
feat(spec,service-analytics): record-mode amounts — per-row currencyField on grouped results, cube door refuses an ungrouped amount aggregate, compile guards on measure currencies (WIP)
Claude-Session: https://claude.ai/code/session_01Utp6Ca8jzC2P7EAQvqjNBb Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5232c8b commit 03062f3

6 files changed

Lines changed: 401 additions & 6 deletions

File tree

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

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import { measureResultType } from './measure-result-type.js';
8181
// `ensureCube`, ahead of both strategies, as the dataset door refuses it at
8282
// compile.
8383
import { assertCubeMeasureFieldTypesAccepted, type MeasureColumn } from './cube-measure-field-type-door.js';
84+
import { assertRecordCurrencyAggregatesGrouped } from './cube-record-currency-door.js';
8485
import type { AnalyticsStrategy, AnalyticsDriverCapabilities, StrategyContext, DatasetScopedStrategyContext, DatasetScope, ReadScopeFilterJudge } from './strategies/types.js';
8586
import { NativeSQLStrategy } from './strategies/native-sql-strategy.js';
8687
import { ObjectQLStrategy } from './strategies/objectql-strategy.js';
@@ -1134,6 +1135,12 @@ export interface AnalyticsServiceConfig {
11341135
* naming no mode, `dynamic` by the spec's default — has no field-level
11351136
* currency; the tenant default applies. `AnalyticsServicePlugin`'s relay
11361137
* is the reference answer.
1138+
* [ADR-0144 D2] `currencyField` is the code field of a `record`-mode
1139+
* amount: answer `currencyConfig.currencyField` ONLY when
1140+
* `currencyConfig.currencyMode` is `'record'`, and `undefined` otherwise.
1141+
* Such an amount has one currency per ROW, so its column carries the
1142+
* `currencyField` pointer instead of a `currency`, and the cube door
1143+
* refuses an aggregate of it that does not group by that field.
11371144
* - Percent scale (objectui#3136): a measure over a `percent` field inherits
11381145
* that field's storage scale via `percentScaleOf`, so a renderer scales by
11391146
* declared metadata instead of guessing from the value.
@@ -1163,7 +1170,7 @@ export interface AnalyticsServiceConfig {
11631170
sourceFieldMeta?: (
11641171
object: string,
11651172
field: string,
1166-
) => { type?: string; multiple?: boolean; defaultCurrency?: string; max?: number } | undefined;
1173+
) => { type?: string; multiple?: boolean; defaultCurrency?: string; currencyField?: string; max?: number } | undefined;
11671174
/**
11681175
* [#15684] The SQL dialect of the datasource backing `object` — `'sqlite'`,
11691176
* `'postgres'`, `'mysql'`, or `undefined` when the host cannot answer.
@@ -2549,6 +2556,13 @@ export class AnalyticsService implements IAnalyticsService {
25492556
if (typeof meta?.type !== 'string' || meta.type === '') return undefined;
25502557
return { type: meta.type, multiple: meta.multiple === true };
25512558
},
2559+
// [ADR-0144, M0-3] …and the declared currency off the same hook, so the
2560+
// compile-time currency guards judge exactly what the column would print.
2561+
declaredCurrency: (object: string, field: string) => {
2562+
const meta = this.sourceFieldMeta?.(object, field);
2563+
if (!meta) return undefined;
2564+
return { type: meta.type, defaultCurrency: meta.defaultCurrency, currencyField: meta.currencyField };
2565+
},
25522566
});
25532567
}
25542568

@@ -3158,10 +3172,24 @@ export class AnalyticsService implements IAnalyticsService {
31583172
// A measure is monetary if it declares a currency OR aggregates a
31593173
// `currency`-type field; non-monetary measures (count, avg of a plain
31603174
// number) never receive a currency code.
3161-
const fc = f as { currency?: string };
3175+
//
3176+
// [ADR-0144 D2] A `record`-mode amount has one currency per ROW, held
3177+
// in its code field, so no single code describes the column and no
3178+
// step of the chain is asked. The column names, by `currencyField`,
3179+
// the selected dimension that groups by that code field: each row's
3180+
// currency is that row's value of it. Read off the dataset and the
3181+
// declaration like every key here, never off the rows. The cube door
3182+
// has already refused an amount aggregate that drops the grouping; a
3183+
// column left without the dimension (a `count` over the amount) gets
3184+
// neither key, rather than a label that is wrong for every other row.
3185+
const fc = f as { currency?: string; currencyField?: string };
31623186
const mc = m as { currency?: string };
31633187
const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : undefined;
3164-
if (fc.currency == null) {
3188+
if (meta?.currencyField !== undefined) {
3189+
const codeColumn = this.selectedDimensions(dataset, selection)
3190+
.find((d) => d.field === meta.currencyField)?.name;
3191+
if (fc.currencyField == null && codeColumn !== undefined) fc.currencyField = codeColumn;
3192+
} else if (fc.currency == null) {
31653193
const monetary = !!mc.currency || meta?.type === 'currency';
31663194
if (monetary) {
31673195
const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
@@ -3566,6 +3594,7 @@ export class AnalyticsService implements IAnalyticsService {
35663594
// [#21044] Judged on the AUGMENTED cube: a suffix-inferred measure and an
35673595
// authored one are one population for the aggregate × field-type table.
35683596
this.assertMeasureFieldTypes(query, augmented);
3597+
this.assertRecordCurrencyGrouped(query, augmented);
35693598
// [#5669] The `where` gate resolves a filter member through dimensions AND
35703599
// measures (that is what the strategies do for a filter member), so it is
35713600
// handed the AUGMENTED cube — a caller filtering on a suffix-inferred
@@ -3582,6 +3611,7 @@ export class AnalyticsService implements IAnalyticsService {
35823611
this.assertDimensionFields(query, cube, Object.keys(cube.dimensions));
35833612
this.assertDimensionsGroupScalarColumns(query, cube);
35843613
this.assertMeasureFieldTypes(query, cube);
3614+
this.assertRecordCurrencyGrouped(query, cube);
35853615
this.assertWhereFields(query, cube, Object.keys(cube.dimensions));
35863616
}
35873617
}
@@ -3624,6 +3654,46 @@ export class AnalyticsService implements IAnalyticsService {
36243654
);
36253655
}
36263656

3657+
/**
3658+
* [ADR-0144 D6] Refuse a `sum` / `avg` / `min` / `max` measure over a
3659+
* `record`-mode amount whose query does not group by the amount's code field
3660+
* — `INVALID_FIELD` / 400, naming the member the caller wrote — before either
3661+
* strategy reads anything. The rule and the envelope are
3662+
* {@link assertRecordCurrencyAggregatesGrouped}'s
3663+
* (`cube-record-currency-door.ts`); this method supplies the answers only the
3664+
* service has: the measure column {@link declaredMeasureColumn} resolves
3665+
* (the #21044 door's resolver), each dimension's column by the same
3666+
* {@link declaredMemberEntry} lookup and hop resolver, and the code field off
3667+
* {@link AnalyticsServiceConfig.sourceFieldMeta}.
3668+
*
3669+
* Runs after the #21044 door, so a pair the field-type table refuses is
3670+
* answered as that first. Same stand-downs: no `sourceFieldMeta`, or a cube
3671+
* whose `sql` is not a bare object name.
3672+
*/
3673+
private assertRecordCurrencyGrouped(query: AnalyticsQuery, cube: Cube): void {
3674+
const fieldMeta = this.sourceFieldMeta;
3675+
if (!fieldMeta) return;
3676+
const object = typeof cube.sql === 'string' ? cube.sql.trim() : '';
3677+
if (!object || !BARE_IDENTIFIER.test(object)) return;
3678+
assertRecordCurrencyAggregatesGrouped(
3679+
query,
3680+
cube.name,
3681+
(member) => declaredMeasureColumn(cube, member, object, this.hopReference),
3682+
(member) => {
3683+
const entry = declaredMemberEntry(cube, member, 'dimension');
3684+
const sql = (entry ? (typeof entry.sql === 'string' ? entry.sql : '') : member).trim();
3685+
if (BARE_IDENTIFIER.test(sql)) return { object, column: sql, path: sql };
3686+
if (!IDENTIFIER_PATH.test(sql)) return null;
3687+
return {
3688+
object: columnObjectOf(cube, object, sql, this.hopReference),
3689+
column: sql.slice(sql.lastIndexOf('.') + 1),
3690+
path: sql,
3691+
};
3692+
},
3693+
(o, field) => fieldMeta(o, field)?.currencyField,
3694+
);
3695+
}
3696+
36273697
/**
36283698
* [#20807] Refuse a GROUPED dimension whose column is a declared
36293699
* structured-JSON field — `INVALID_FIELD` / 400, naming the member the caller
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0144 D6] The cube door's record-currency judgment: a `sum` / `avg` /
5+
* `min` / `max` measure over a `currencyMode: 'record'` amount is refused
6+
* `INVALID_FIELD` / 400 unless the same query groups by that amount's code
7+
* field (`currencyConfig.currencyField`), ahead of strategy selection.
8+
*
9+
* ## The shape this closes
10+
*
11+
* A record-mode amount is a bare number (ADR-0104 D1) whose currency lives in a
12+
* sibling code field, one per row. An aggregate that does not group by the code
13+
* adds, averages or orders numbers in different currencies: the measurement
14+
* round on #8345 (probe A) saw a USD row of 100 and an EUR row of 100 answer
15+
* `200`, labelled with the tenant currency. Grouped by the code, every group
16+
* holds one currency and the answer is right (probe C); the per-row label is
17+
* the result column's `currencyField` (`AnalyticsResult.fields`,
18+
* `@objectstack/spec/contracts`).
19+
*
20+
* ## Where it stands
21+
*
22+
* In `AnalyticsService.ensureCube`, on every path out of it, right after the
23+
* #21044 measure × field-type door (`cube-measure-field-type-door.ts`): ahead
24+
* of both strategies, for `query()` and `generateSql()` alike. A dataset
25+
* selection reaches it through `DatasetExecutor`, so its primary grid, every
26+
* `totals` grouping (the grand total `[]` included) and the `compareTo` pass
27+
* are judged one by one: a grouping that drops the code is a sum across
28+
* currencies like any other.
29+
*
30+
* ## What it judges
31+
*
32+
* - Every `measures` entry that resolves to a cube measure of type `sum`,
33+
* `avg`, `min` or `max` (ADR-0144 open item 10). `count` and
34+
* `count_distinct` read no amount and are not judged.
35+
* - The measure's column, located on the object that declares it by the
36+
* caller's resolver (`declaredMeasureColumn`, the one the #21044 door reads).
37+
* - "Grouped by the code" means a `dimensions` entry whose column is the code
38+
* field on that SAME object. A time dimension is no currency group, and a
39+
* `where` that happens to pin one code is not judged: the rule is the
40+
* grouping, so an answer's label never depends on what a filter selected.
41+
*
42+
* ## Not judged — "cannot answer, do not block"
43+
*
44+
* - A host that wires no `sourceFieldMeta`, or a cube whose `sql` is not a
45+
* bare object name (the caller stands down for both).
46+
* - A member that resolves to no declared measure, a `sql` that is not a
47+
* column reference, or a column whose declaration names no `record` mode.
48+
*
49+
* ## The envelope
50+
*
51+
* `INVALID_FIELD` / 400 through `invalidMemberError` (ADR-0112), with the
52+
* column and its object attached, as the #21044 door attaches them: a verdict
53+
* about one member the request named. The words carry the prescription:
54+
* group by the code field. A consolidated total over the stored home-currency
55+
* amount is ADR-0144 D5's, which ships with the write-time projection.
56+
*/
57+
58+
import type { AnalyticsQuery } from '@objectstack/spec/contracts';
59+
import type { MeasureColumn } from './cube-measure-field-type-door.js';
60+
import { invalidMemberError } from './dataset-refusal.js';
61+
62+
/** The aggregates that read the amount itself (ADR-0144 open item 10). */
63+
const AMOUNT_AGGREGATES: ReadonlySet<string> = new Set(['sum', 'avg', 'min', 'max']);
64+
65+
/**
66+
* Refuse the first `measures` entry of `query` that aggregates a record-mode
67+
* amount without grouping by its code field — `INVALID_FIELD` / 400. See the
68+
* module header.
69+
*
70+
* @param measureOf - The cube measure a `measures` entry resolves to: its
71+
* `type`, and the column it aggregates (`null` when its `sql` is not a column
72+
* reference) — or `undefined` when it resolves to no declared measure.
73+
* @param dimensionColumnOf - The column a `dimensions` entry groups by, located
74+
* the same way, or `null` when this door cannot pin one.
75+
* @param recordCurrencyFieldOf - The `currencyConfig.currencyField` of a column
76+
* declared `currencyMode: 'record'`, or `undefined` for any other column.
77+
*/
78+
export function assertRecordCurrencyAggregatesGrouped(
79+
query: AnalyticsQuery,
80+
cubeName: string,
81+
measureOf: (member: string) => { type: unknown; column: MeasureColumn | null } | undefined,
82+
dimensionColumnOf: (member: string) => MeasureColumn | null,
83+
recordCurrencyFieldOf: (object: string, field: string) => string | undefined,
84+
): void {
85+
const measures = query.measures ?? [];
86+
if (measures.length === 0) return;
87+
let grouped: Array<MeasureColumn | null> | undefined;
88+
for (const member of measures) {
89+
const measure = measureOf(member);
90+
if (!measure?.column || typeof measure.type !== 'string' || !AMOUNT_AGGREGATES.has(measure.type)) continue;
91+
const target = measure.column;
92+
const codeField = recordCurrencyFieldOf(target.object, target.column);
93+
if (codeField === undefined) continue;
94+
grouped ??= (query.dimensions ?? []).map(dimensionColumnOf);
95+
if (grouped.some((d) => d?.object === target.object && d.column === codeField)) continue;
96+
97+
const codePath = target.path === target.column
98+
? codeField
99+
: `${target.path.slice(0, target.path.lastIndexOf('.') + 1)}${codeField}`;
100+
const err = invalidMemberError(
101+
`Measure '${member}' on cube '${cubeName}' takes the ${measure.type} of field '${target.path}', `
102+
+ `a record-currency amount whose currency is each record's '${codeField}', but the query does not `
103+
+ `group by '${codePath}', so the query was NOT run. Add the dimension over '${codePath}' to the `
104+
+ `query, which answers one row per currency. Without the grouping the ${measure.type} would `
105+
+ 'combine amounts in different currencies into one number.',
106+
{ member, param: 'measures', cube: cubeName },
107+
) as Error & { field?: string; object?: string };
108+
err.field = target.path;
109+
err.object = target.object;
110+
throw err;
111+
}
112+
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from '@objectstack/spec/ui';
1919
import type { FilterCondition } from '@objectstack/spec/data';
2020
import { datasetInvalidError } from './dataset-refusal.js';
21+
import { assertMeasureCurrenciesCoherent, type DeclaredCurrency } from './measure-currency-coherence.js';
2122
import { TEMPORAL_SOURCE_FIELD_TYPES } from './measure-result-type.js';
2223
import type { DurationMeasureSpec } from './strategies/types.js';
2324

@@ -194,6 +195,16 @@ export interface DatasetCompileOptions {
194195
* `declaredFieldType` (the type alone) until then.
195196
*/
196197
declaredValueShape?: (objectName: string, field: string) => ValueShapeFieldDef | undefined;
198+
/**
199+
* [ADR-0144, M0-3] The declared CURRENCY of `field` on `objectName` — its
200+
* type, its `fixed` code and its `record` code field — or `undefined` when
201+
* nothing authoritative can answer. Supplied by the host from the same
202+
* `AnalyticsServiceConfig.sourceFieldMeta` the result-column currency chain
203+
* reads, so the compile-time guards of `measure-currency-coherence.ts` judge
204+
* the currency the column would print. Absent → only an explicit measure
205+
* `currency` is known, and the guards judge what that alone proves.
206+
*/
207+
declaredCurrency?: (objectName: string, field: string) => DeclaredCurrency | undefined;
197208
}
198209

199210
/** Map a dataset measure's aggregate to the Cube metric `type`. */
@@ -843,6 +854,11 @@ export function compileDataset(
843854
if (m.filter) measureFilters[m.name] = m.filter;
844855
}
845856

857+
// [ADR-0144, M0-3] A measure `currency` contradicting its field, and a
858+
// derived measure across differing currencies, are refused HERE: both are
859+
// properties of the document, decidable from metadata before any query runs.
860+
assertMeasureCurrenciesCoherent(dataset, options?.declaredCurrency);
861+
846862
const cube: Cube = {
847863
name: dataset.name,
848864
// [#6761] The cube's own display title, same rule. `Cube.title` is optional

0 commit comments

Comments
 (0)