Skip to content

Commit 3604371

Browse files
feat(spec,service-analytics): record-currency analytics — grouped answers name a per-row currency, the cube door refuses an ungrouped amount aggregate, compile guards on measure currencies (#27338)
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5 <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg9oTskgBVZmtxXLAEh251Cb)_ Fixes #27187 ⛔ not released alone — R1: pairs with #27184, #27186, #27187, #27188 (one Version Packages release) Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up owed — #27189 ## 维护者速读 - **改了什么:** 分析结果里,record 币种金额的度量列新增 `currencyField`,指向按币种分组的那一列,每行的币种就是该行这列的值;cube 门拒收不按币种字段分组的 sum/avg/min/max(所有路径:数据集与已注册 cube、按对象名的临时查询、草稿预览);数据集编译期新增两条只针对 record 币种的护栏:record 金额上写死度量 `currency` 拒收(派生度量结果是 record 币种的也拒收),派生度量把 record 金额与别的币种混算拒收。 - **为什么改:** 测量轮探针 C:按币种分组的结果整列只标一个币种,每组都标错;探针 A:跨币种相加得出一个没有币种的数。 - **风险与回滚:** 只新增,不收窄:护栏只管随本期发布的 record 模式,以前能编译的写法一律不受影响;回滚即还原本 PR。契约键走 CONTRACT_REVIEW_TIER 复核。 - **先看:** `packages/services/service-analytics/src/cube-record-currency-door.ts`(拦截判定)、`measure-currency-coherence.ts` 的 `recordPairDiffers`(何时算"与 record 金额币种不同")、`packages/spec/src/contracts/analytics-service.ts` 新键说明。 - **两分钟验证:** `pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/record-currency-analytics.test.ts`,预期 29 passed(两种策略 + 临时查询 + 预览路径 + 护栏)。 - **需要你做的:** 无,席位落地。 ## What changes The record currency mode (#27268) has landed on `main`, and `main` is brought in by the merge commit `48cdeb43` (no rebase) through `scripts/pm/os-regen-merge.sh`. The script took main's side of `packages/spec/authorable-surface/data.json` (`f79f10d0`), and `check:generated` then read all 14 generated artifacts up to date, so no regeneration commit was needed. The diff against `main` is this card's own 10 files, +824 / -8. The contract-review fixes are `0d2291da`. **Contract (spec).** `AnalyticsResult.fields[].currencyField?: string` (`contracts/analytics-service.ts`), and the same key on `AnalyticsResultResponseSchema` (`api/analytics.zod.ts`), which the existing type-identity pin in `api/analytics.test.ts` holds equal to the contract. On a measure column over a `currencyMode: 'record'` amount, it names the dimension column of the same answer that groups by the amount's code field. Each row's currency is that row's value of that column, and the column carries no `currency`. **Consumer (service-analytics).** - `plugin.ts` relays `currencyField` through `sourceFieldMeta` only under `currencyMode: 'record'`, by the same rule that relays `defaultCurrency` only under `fixed`. - `enrichResultColumns` sets the pointer from the selected dimensions. It reads the dataset and the declaration, not the rows, so the live path and the draft preview path agree. A record-mode column with no code dimension gets neither key, and a `count` / `count_distinct` over the amount gets neither key, grouped or not. - `cube-record-currency-door.ts` runs on all three exits of `ensureCube` (the inferred cube that an ad-hoc `query` over the object mints, the augmented cube and the declared one), after the #21044 field-type door and ahead of both strategies. The ADR-0037 draft-preview evaluator never reaches `ensureCube`, so its per-query gate (`previewService.query`) calls the same judgment. A `sum` / `avg` / `min` / `max` over a record-mode amount whose `dimensions` do not include the code field (on the same object) is refused `INVALID_FIELD` / 400 with `member`, `param`, `cube`, `field` and `object`. Dataset `totals` groupings reach it one by one, so a grand total over the amount is refused too. - `measure-currency-coherence.ts`, called from `compileDataset` (`DATASET_INVALID` / 400): - a measure `currency` over a record-mode amount, whose currency is per row, and a measure `currency` on a derived measure whose resolved currency is the record mode; - a derived measure (any op) that combines a record-mode amount with a monetary operand in a provably different currency: another code field, a `fixed` code, an explicit code or the tenant's. - Both judge the record mode alone, so nothing that compiled before is refused. The same shapes over `fixed` fields only (a measure `currency` contradicting a `fixed` field, a derived measure over two different `fixed` codes) are not judged; see Acceptance notes. **Docs.** `content/docs/api/data-api.mdx` lists `currencyField` among the optional `fields[]` members, which that page says mirror `AnalyticsResultResponseSchema` member for member. It also states the record-mode column and the ungrouped-aggregate refusal next to the currency chain (`d24c889d`, prompted by the docs-drift comment). ## Why ADR-0144 D2/D6 and workload row M0-3: the per-row currency on grouped results, the cube door's refusal, and the guards on the record mode. Measurement round on #8345: probe C (grouped values right, one label per column), probe A (`m_mixed_sum` 200 with no currency). ## Tests <details><summary>New suite: src/__tests__/record-currency-analytics.test.ts — 29/29</summary> Real ObjectQL engine over `SqlDriver` (better-sqlite3), the plugin's own composition, run on two faces: `native` (plugin capabilities, NativeSQLStrategy) and `objectql` (narrowed to the engine aggregate). Per face: grouped by code gives `{ USD: 150, EUR: 100 }` and `currencyField: 'currency'` with no `currency`; a totals grouping that keeps the code is served; three refusals (grouped by another dimension, no grouping, a grand total) answer `INVALID_FIELD` / 400 with field `amount` and the object, and the first two read nothing; a `count` is served ungrouped with neither key, and a `count` whose `field` is the amount gets neither key when grouped; the cube face refuses the same thing; the inferred cube (no registered dataset, `amount_sum`) refuses the ungrouped amount before anything is read and serves it grouped by `currency_code`. The draft preview path carries the pointer and refuses the ungrouped amount. Compile guards: a measure `currency` over the record amount is refused, and so is one on a derived sum of record amounts; a record amount derived with a fixed amount (all four ops) or with a tenant amount is refused, including one level up through a derived sum. The controls compile: a measure `currency` over a fixed field (contradicting or restating it), a derived sum of two different fixed codes, an amount per invoice, an amount with itself, and a fixed amount with the tenant one. </details> <details><summary>Existing pins unchanged</summary> `currency-mode-relay.test.ts` (`m_explicit_fixed`: JPY over a fixed-EUR field still answers JPY) and `query-dataset.test.ts` are byte-identical to `main` at f79f10d. They were moved under the earlier narrowing ruling and have since been restored. </details> <details><summary>Package runs</summary> At f79f10d, with `main` merged in, under the verify lock: `pnpm install --frozen-lockfile`, `pnpm --filter '@objectstack/service-analytics...' build`, then service-analytics `typecheck && test`, gave `VERDICT command-exit 0`, Test Files 196 passed (196), Tests 4734 passed / 282 skipped. The same counts as at 0d2291d. Spec: `tsc --noEmit -p tsconfig.json` 0 errors; `vitest run src/api/analytics.test.ts` 34/34; `check:generated` "All 14 generated artifacts are up to date", taken against the dist that build produced. Nothing needed regenerating after the merge. </details> <details><summary>Ablations (src-resolved; ablation-replace.mjs; every leg restored blob == HEAD, git diff HEAD empty)</summary> All at 0d2291d (the suite itself 29/29 there). The merge of `main` changed none of this card's files: - M1, the inferred-cube door call removed → 2 failed / 29 (the inferred-cube case on both faces) - M2, the draft-preview door call removed → 1 failed / 29 (the preview refusal) - S1, the derived arm of guard 1 disabled → 1 failed / 29 - S2, the amount-aggregate check on the pointer forced true → 2 failed / 29 (the grouped test on both faces, at `priced_invoices`) - door: grouping check replaced by `continue` → 11 failed / 29 - per-row pointer assignment deleted → 5 failed / 29 - guard 1, the record arm disabled → 1 failed / 29 - guard 2, `recordPairDiffers` forced false → 6 failed / 29 </details> ## Acceptance notes - **Guards confined to the record mode (seat ruling, revised after the ADR contract review).** The fixed-only arms (a measure `currency` contradicting a `fixed` field's code; a derived measure over two different `fixed` codes) are outside ruling #27119 and ADR-0144, so R1 has no authority to narrow there. They were dropped at 507ed8f, the `m_explicit_fixed` pin was restored, and the lines are back to `yes (widening)` / `added`. The changeset carries no BREAKING paragraph and no ADR-0087 marker. `check-adr-0087-registration`, `check-changeset-no-major` and `check-empty-changeset` all exit 0 at 0d2291d. ADR-0144 Open item 12 has since been ruled, letter A ([`6486427200`](#8345 (comment)) on #8345): both fixed-only shapes are refused on a card of their own after R1, and this PR stays record-mode only. - **ADR-0144 citations re-checked against the landed text** (`3eaecb21`). The fixed-only scope now cites open item 12 and its ruling, and a consolidated total cites D6 (it reads the home field D5 stores) where it had cited D5. - **Contract review at ae8e3d4: FAIL** ([`6490225184`](#27338 (comment))). Both must-fixes and two should-fixes are in `0d2291da`: M1 (the inferred-cube exit is judged), M2 (the draft preview is judged), S1 (a `currency` on a record-resolved derived measure is refused) and S2 (no pointer on a `count`). Each has its ablation above. - **Follow-on, not R1 (S3).** A derived `sum` / `difference` of record-mode operands over one code field, grouped by it, carries neither key by contract, although its currency is computable from the same resolver. A renderer shows a plain number, which is not a mislabel. - **Wire key spelling: settled by the contract review, keep `currencyField`.** On the wire it names a result column, consistent with `fields[].name`. - The cube face (`query`) carries no per-row pointer, as it carries no `currency` today. Only the refusal applies there. - A relationship-path amount (`account.amount`) is refused by the cube door when ungrouped, because the door locates the column through the hop resolver. The column enrichment and the compile guards stand down on dotted fields, the same tier as the existing compile field-type door. - A `where` that pins one currency code is not counted as grouping. The rule is the grouping alone. - An explicit measure `currency` over a `dynamic` field is not judged (Q6's third guard). It is not in this card. ## Verification Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at 0d2291d, against a fresh `origin/main` (`968925c8`), gave 127 commands. Each ran with its exit code captured before any pipe. Reconciliation (`--ran`, exit codes recorded): `127 derived famil(ies) accounted for — 126 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. All 126 measured gates exited 0. NOT MEASURED, prerequisite refused locally (exit 3), CI-owned: - `node scripts/check-plugin-teardown-shape.mjs --self-test`: the shallow checkout cannot reach its pinned positive-control commit. Pre-push checks, run by hand before each push: `check-regen-pending --pre-push` exit 0; `check-commit-card-trailers --pre-push` exit 0 (no card relation and no model identifier on any pushed commit). At f79f10d, after the merge of `main` (no code change): `check-adr-0087-registration`, `check-changeset-no-major` and `check-empty-changeset` exit 0 against `origin/main`. Full dogfood: n/a. The title has no `!` and the Clause-② arm is `(widening)`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran (true); `pnpm lint` is the CI-owned repo scan, not run locally. - [x] 4. One issue, one branch, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6Ca8jzC2P7EAQvqjNBb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ded67bf commit 3604371

10 files changed

Lines changed: 824 additions & 8 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-analytics': minor
4+
---
5+
6+
feat(spec,service-analytics): analytics over a record-currency amount — grouped answers name a per-row currency, an ungrouped amount aggregate is refused, and two compile-time guards on record-mode measures (ADR-0144 D2, D6)
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
A measure column over a `currencyMode: 'record'` amount now carries `fields[].currencyField` on a `queryDataset` answer: the name of the dimension column that groups by the amount's code field. Each row's currency is that row's value of it, and the column carries no `currency`. Before, a breakdown by currency labelled every group with one code.
12+
13+
- `AnalyticsResult.fields[].currencyField` (`@objectstack/spec/contracts`) and the same key on `AnalyticsResultResponseSchema` (`@objectstack/spec/api`).
14+
- The cube door refuses a `sum`, `avg`, `min` or `max` over a record-mode amount whose query does not group by the code field, with `INVALID_FIELD` / 400 naming the measure, the field and its object. It judges every path: a dataset or registered cube, an ad-hoc `query` over the object (`amount_sum`), and a draft preview. A `totals` grouping that drops the code, the grand total included, is refused the same way. `count` reads no amount and is served, and its column carries neither currency key.
15+
16+
Two compile-time refusals, `DATASET_INVALID` / 400, on the record mode alone, so nothing that compiled before is refused:
17+
18+
- a measure `currency` over a record-mode amount, whose currency is per row, including on a derived measure that resolves to one;
19+
- a derived measure that combines a record-mode amount with an operand in another currency (another code field, a `fixed` code, an explicit code or the tenant's), for every op. A count operand is not compared, so an amount-per-invoice ratio still compiles.
20+
21+
The same two shapes over `fixed` fields only are not judged here; ADR-0144 open item 12 refuses them on a card of its own after this release.
22+
23+
⛔ Not released alone: this ships in the same release as the record currency mode, its write refusals and the per-row display.

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ is executed. `POST /analytics/sql` refuses the same keys. To order by a member,
474474
<Callout type="info">
475475
**`fields[]` is the resolved presentation surface — read it first.** Each entry carries
476476
`name` and `type` and, when the producer declares them, `label`, `format`, `currency`,
477-
`percentScale`, `builtinAggregate`, `aggregate`, `nullLabel` and `polarity` (which direction
477+
`currencyField`, `percentScale`, `builtinAggregate`, `aggregate`, `nullLabel` and `polarity` (which direction
478478
of a measure's change is good news, relayed from the dataset measure): the optional members
479479
`AnalyticsResultResponseSchema` declares (`packages/spec/src/api/analytics.zod.ts`),
480480
mirrored member for member by `IAnalyticsService.query`'s `AnalyticsResult` and bound to
@@ -500,6 +500,13 @@ tenant default currency (the `localization.currency` setting), if one is set. A
500500
so its column shows the tenant default. The cube query on this page carries no column
501501
`currency`.
502502

503+
A measure column over a `currencyMode: 'record'` amount has one currency per row, so it
504+
carries `currencyField` instead of `currency`: the name of the dimension column, in the same
505+
answer, that groups by the amount's code field (`currencyConfig.currencyField`). Each row's
506+
currency is that row's value of that column. A `sum`, `avg`, `min` or `max` over such an
507+
amount is refused `INVALID_FIELD` / 400 unless the query groups by the code field, on the
508+
dataset query and the cube query alike; a `count` is served.
509+
503510
The dataset query also states each measure column's `aggregate` (`count`, `sum`, `avg`,
504511
`min`, `max`, `count_distinct`), whether or not the author labelled the measure, so a
505512
chart can tell a count from a sum. `builtinAggregate` is narrower: it is present only

‎packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts‎

Lines changed: 363 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 83 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 { AMOUNT_AGGREGATES, 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

@@ -2711,6 +2725,10 @@ export class AnalyticsService implements IAnalyticsService {
27112725
// carries the same `internal` fields the published ones do.
27122726
this.assertNoInternalFieldsQueried(q, compiled.cube, previewScope);
27132727
await this.assertFieldsReadable(q, compiled.cube, previewScope, context);
2728+
// [ADR-0144 D6] …and the record-currency door, which the published
2729+
// path meets in `ensureCube`: a draft preview must not serve the
2730+
// sum across currencies that the published dataset refuses.
2731+
this.assertRecordCurrencyGrouped(q, compiled.cube);
27142732
return evaluateAnalyticsQueryOverRows(
27152733
q,
27162734
compiled.cube,
@@ -3158,10 +3176,26 @@ export class AnalyticsService implements IAnalyticsService {
31583176
// A measure is monetary if it declares a currency OR aggregates a
31593177
// `currency`-type field; non-monetary measures (count, avg of a plain
31603178
// number) never receive a currency code.
3161-
const fc = f as { currency?: string };
3179+
//
3180+
// [ADR-0144 D2] A `record`-mode amount has one currency per ROW, held
3181+
// in its code field, so no single code describes the column and no
3182+
// step of the chain is asked. The column names, by `currencyField`,
3183+
// the selected dimension that groups by that code field: each row's
3184+
// currency is that row's value of it. Read off the dataset and the
3185+
// declaration like every key here, never off the rows. The cube door
3186+
// has already refused an amount aggregate that drops the grouping; a
3187+
// column left without the dimension gets neither key, rather than a
3188+
// label that is wrong for every other row. A `count` over the amount
3189+
// reads no amount, so it gets neither key either, grouped or not.
3190+
const fc = f as { currency?: string; currencyField?: string };
31623191
const mc = m as { currency?: string };
31633192
const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : undefined;
3164-
if (fc.currency == null) {
3193+
if (meta?.currencyField !== undefined) {
3194+
const codeColumn = AMOUNT_AGGREGATES.has(m.aggregate ?? '')
3195+
? this.selectedDimensions(dataset, selection).find((d) => d.field === meta.currencyField)?.name
3196+
: undefined;
3197+
if (fc.currencyField == null && codeColumn !== undefined) fc.currencyField = codeColumn;
3198+
} else if (fc.currency == null) {
31653199
const monetary = !!mc.currency || meta?.type === 'currency';
31663200
if (monetary) {
31673201
const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
@@ -3483,6 +3517,10 @@ export class AnalyticsService implements IAnalyticsService {
34833517
// [#21044] …and a measure's aggregate must be one the aggregate ×
34843518
// field-type table accepts for its column's declared type.
34853519
this.assertMeasureFieldTypes(query, cube);
3520+
// [ADR-0144 D6] …and an amount aggregate over a record-currency field
3521+
// must group by its code field: this ad-hoc exit is the one
3522+
// `ObjectStackDataSource.aggregate()` reaches (`${field}_${fn}`).
3523+
this.assertRecordCurrencyGrouped(query, cube);
34863524
// [#5669] …and the `where`'s, third and last of the three request keys that
34873525
// carry a field name. Its members are read from the filter TREE, not from
34883526
// `cube.dimensions` — which on this path was minted from this very query,
@@ -3566,6 +3604,7 @@ export class AnalyticsService implements IAnalyticsService {
35663604
// [#21044] Judged on the AUGMENTED cube: a suffix-inferred measure and an
35673605
// authored one are one population for the aggregate × field-type table.
35683606
this.assertMeasureFieldTypes(query, augmented);
3607+
this.assertRecordCurrencyGrouped(query, augmented);
35693608
// [#5669] The `where` gate resolves a filter member through dimensions AND
35703609
// measures (that is what the strategies do for a filter member), so it is
35713610
// handed the AUGMENTED cube — a caller filtering on a suffix-inferred
@@ -3582,6 +3621,7 @@ export class AnalyticsService implements IAnalyticsService {
35823621
this.assertDimensionFields(query, cube, Object.keys(cube.dimensions));
35833622
this.assertDimensionsGroupScalarColumns(query, cube);
35843623
this.assertMeasureFieldTypes(query, cube);
3624+
this.assertRecordCurrencyGrouped(query, cube);
35853625
this.assertWhereFields(query, cube, Object.keys(cube.dimensions));
35863626
}
35873627
}
@@ -3624,6 +3664,46 @@ export class AnalyticsService implements IAnalyticsService {
36243664
);
36253665
}
36263666

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

0 commit comments

Comments
 (0)