Skip to content

Commit 507ed8f

Browse files
committed
fix(service-analytics): the compile guards judge record-mode amounts only — the fixed-only arms are not R1's to narrow; the two chain pins restored, the changeset back to widening / added
Claude-Session: https://claude.ai/code/session_01Utp6Ca8jzC2P7EAQvqjNBb Co-authored-by: Claude <noreply@anthropic.com>
1 parent fea128c commit 507ed8f

6 files changed

Lines changed: 75 additions & 88 deletions

File tree

‎.changeset/27187-record-currency-analytics.md‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,21 @@
33
'@objectstack/service-analytics': minor
44
---
55

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 measure-currency guards (ADR-0144 D2, D6)
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)
77

8-
Clause-②: yes (narrowing)
9-
Metadata-protocol: narrowed
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
1010

1111
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.
1212

1313
- `AnalyticsResult.fields[].currencyField` (`@objectstack/spec/contracts`) and the same key on `AnalyticsResultResponseSchema` (`@objectstack/spec/api`).
1414
- 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. A `totals` grouping that drops the code, the grand total included, is refused the same way. `count` reads no amount and is served.
1515

16-
**BREAKING** (`@objectstack/service-analytics`): two compile-time refusals, `DATASET_INVALID` / 400, for dataset metadata that compiled before and printed a wrong label or a wrong number.
16+
Two compile-time refusals, `DATASET_INVALID` / 400, on the record mode alone, so nothing that compiled before is refused:
1717

18-
- A measure `currency` that contradicts its field: a code other than the `fixed` field's own, or any code over a record-mode amount. The column printed the field's amounts under the measure's code. A code equal to the `fixed` field's is still accepted, and a measure `currency` over a `dynamic` field is not judged.
19-
- A derived measure whose monetary operands are in currencies that provably differ (two different codes, or a record-mode amount against anything but itself), for every op. The derived value combined bare numbers in two currencies and carried no currency at all. A count operand is not compared, so a revenue-per-deal ratio still compiles.
18+
- a measure `currency` over a record-mode amount, whose currency is per row;
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.
2020

21-
Each refusal names the measure and says what to delete or change. No in-tree dataset declares either shape.
22-
23-
<!-- adr-0087: not-required (no-migration-prescription) the two refusals reject a contradiction in a dataset's own measures at compile and name the fix in their message; there is no stored shape to convert and no key is removed -->
21+
The same two shapes over `fixed` fields only are not judged.
2422

2523
⛔ Not released alone: this ships in the same release as the record currency mode, its write refusals and the per-row display.

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,10 @@ const DATASET = DatasetSchema.parse({
9898
{ name: 'm_nomode', aggregate: 'sum', field: 'amt_nomode' },
9999
{ name: 'm_emptycfg', aggregate: 'sum', field: 'amt_emptycfg' },
100100
{ name: 'm_bare', aggregate: 'sum', field: 'amt_bare' },
101-
// An explicit measure `currency` wins over a dynamic field's tenant
102-
// default. [ADR-0144 M0-3] Over a FIXED field it may only restate the
103-
// field's code: a contradicting one is refused at compile
104-
// (`measure-currency-coherence.ts`, record-currency-analytics.test.ts).
101+
// An explicit measure `currency` wins over every field-side answer,
102+
// the field's FIXED currency included.
105103
{ name: 'm_explicit_dynamic', aggregate: 'sum', field: 'amt_dynamic', currency: 'JPY' },
106-
{ name: 'm_explicit_fixed', aggregate: 'sum', field: 'amt_fixed', currency: 'EUR' },
104+
{ name: 'm_explicit_fixed', aggregate: 'sum', field: 'amt_fixed', currency: 'JPY' },
107105
{ name: 'm_units', aggregate: 'sum', field: 'units' },
108106
],
109107
});
@@ -222,11 +220,11 @@ describe.each<Shape>(['raw', 'parsed'])('[#20091] result-column currency honours
222220
expect(preview.m_bare).toBe('USD');
223221
});
224222

225-
it("an explicit measure currency wins over a dynamic field, and restates a fixed one's code", () => {
223+
it('an explicit measure currency wins, over a dynamic field and over a fixed one', () => {
226224
expect(live.m_explicit_dynamic).toBe('JPY');
227225
expect(preview.m_explicit_dynamic).toBe('JPY');
228-
expect(live.m_explicit_fixed).toBe('EUR');
229-
expect(preview.m_explicit_fixed).toBe('EUR');
226+
expect(live.m_explicit_fixed).toBe('JPY');
227+
expect(preview.m_explicit_fixed).toBe('JPY');
230228
});
231229

232230
it('CONTROL: a non-monetary measure carries no currency', () => {

‎packages/services/service-analytics/src/__tests__/query-dataset.test.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,8 @@ describe('AnalyticsService.queryDataset', () => {
153153
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('GBP');
154154
});
155155

156-
// [ADR-0144 M0-3] Over a FIXED field a measure currency may only restate the
157-
// field's code — a contradicting one is refused at compile
158-
// (`measure-currency-coherence.ts`, pinned in record-currency-analytics.test.ts).
159-
it('chain: an explicit measure currency wins over the tenant ctx on a dynamic field', async () => {
160-
const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency' } : undefined);
156+
it('chain: an explicit measure currency wins over the field default and the tenant ctx', async () => {
157+
const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency', defaultCurrency: 'EUR' } : undefined);
161158
const r = await svc.queryDataset(moneyDataset({ currency: 'JPY' }), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'GBP' } as ExecutionContext) as any;
162159
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('JPY');
163160
});

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

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@
1919
* own capabilities; `ObjectQLStrategy`, narrowed to the engine aggregate),
2020
* and the column on the draft-preview path, which shares the one
2121
* column-description seam.
22-
* - The two compile-time guards (`measure-currency-coherence.ts`):
23-
* `DATASET_INVALID` / 400 for a measure `currency` that contradicts its
24-
* field, and for a derived measure across currencies that provably differ.
22+
* - The two compile-time guards (`measure-currency-coherence.ts`), both on
23+
* the record mode alone: `DATASET_INVALID` / 400 for a measure `currency`
24+
* over a record-mode amount, and for a derived measure combining one with an
25+
* operand in another currency. The same shapes over `fixed` fields only
26+
* still compile (controls below).
2527
*
2628
* The SQLite cell runs everywhere; the strategies are the axis here, not the
2729
* dialect (the door stands ahead of both and reads nothing).
@@ -263,20 +265,13 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
263265
expect(err?.message).toContain(`"${named}"`);
264266
};
265267

266-
it('a measure currency contradicting a fixed field is refused', () => {
267-
expectRefused([{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }], 'eur_as_jpy');
268-
});
269-
270-
it("a measure currency restating a fixed field's code is accepted", () => {
271-
expect(compileRefusal([{ name: 'eur', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' }])).toBeUndefined();
272-
});
273-
274268
it('a measure currency over a record-currency amount is refused', () => {
275269
expectRefused([{ name: 'total_usd', aggregate: 'sum', field: 'amount', currency: 'USD' }], 'total_usd');
276270
});
277271

278-
it('a measure currency over a dynamic field is not judged (the tenant currency is unknown at compile)', () => {
279-
expect(compileRefusal([{ name: 'tenant_as_usd', aggregate: 'sum', field: 'amt_tenant', currency: 'USD' }])).toBeUndefined();
272+
it('CONTROL: a measure currency over a fixed field is not judged, whatever its code (an ADR-0144 open item)', () => {
273+
expect(compileRefusal([{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }])).toBeUndefined();
274+
expect(compileRefusal([{ name: 'eur', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' }])).toBeUndefined();
280275
});
281276

282277
const base = [
@@ -288,27 +283,31 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
288283
];
289284

290285
for (const op of ['sum', 'difference', 'ratio', 'product'] as const) {
291-
it(`a derived ${op} across two fixed currencies is refused`, () => {
292-
expectRefused([...base, { name: 'mixed', derived: { op, of: ['usd', 'eur'] } }], 'mixed');
286+
it(`a derived ${op} of a record-currency amount and a fixed one is refused`, () => {
287+
expectRefused([...base, { name: 'mixed', derived: { op, of: ['total', 'usd'] } }], 'mixed');
293288
});
294289
}
295290

296-
it('a derived measure across a record-currency amount and a fixed one is refused', () => {
297-
expectRefused([...base, { name: 'mixed', derived: { op: 'difference', of: ['total', 'usd'] } }], 'mixed');
291+
it('a derived measure of a record-currency amount and a tenant-currency one is refused', () => {
292+
expectRefused([...base, { name: 'mixed', derived: { op: 'sum', of: ['tenant', 'total'] } }], 'mixed');
298293
});
299294

300-
it('a derived measure built on a mixed-currency derived sum is refused at the first mixed one', () => {
295+
it('a derived measure built on a record-currency derived sum is judged at the next level', () => {
301296
expectRefused(
302-
[...base, { name: 'usd_twice', derived: { op: 'sum', of: ['usd', 'usd'] } }, { name: 'mixed', derived: { op: 'sum', of: ['usd_twice', 'eur'] } }],
297+
[...base, { name: 'total_twice', derived: { op: 'sum', of: ['total', 'total'] } }, { name: 'mixed', derived: { op: 'sum', of: ['total_twice', 'eur'] } }],
303298
'mixed',
304299
);
305300
});
306301

307-
it('accepted: one currency with a count (revenue per invoice), one fixed with the tenant (not provably different)', () => {
302+
it('CONTROL: a derived measure over two different fixed codes is not judged (an ADR-0144 open item)', () => {
303+
expect(compileRefusal([...base, { name: 'mixed', derived: { op: 'sum', of: ['usd', 'eur'] } }])).toBeUndefined();
304+
});
305+
306+
it('accepted: an amount per invoice, an amount with itself, a fixed amount with the tenant currency', () => {
308307
expect(
309308
compileRefusal([
310309
...base,
311-
{ name: 'per_invoice', derived: { op: 'ratio', of: ['usd', 'invoices'] } },
310+
{ name: 'per_invoice', derived: { op: 'ratio', of: ['total', 'invoices'] } },
312311
{ name: 'usd_and_tenant', derived: { op: 'sum', of: ['usd', 'tenant'] } },
313312
{ name: 'total_share', derived: { op: 'ratio', of: ['total', 'total'] } },
314313
]),

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -854,9 +854,9 @@ export function compileDataset(
854854
if (m.filter) measureFilters[m.name] = m.filter;
855855
}
856856

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.
857+
// [ADR-0144, M0-3] A measure `currency` over a record-mode amount, and a
858+
// derived measure combining one with another currency, are refused HERE: both
859+
// are properties of the document, decidable from metadata before any query runs.
860860
assertMeasureCurrenciesCoherent(dataset, options?.declaredCurrency);
861861

862862
const cube: Cube = {

‎packages/services/service-analytics/src/measure-currency-coherence.ts‎

Lines changed: 37 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,41 @@
22

33
/**
44
* [ADR-0144, multi-currency M0-3] Two compile-time guards on a dataset's
5-
* measure currencies — the guards the measurement round on #8345 (its Q6)
6-
* found missing on today's surface, `DATASET_INVALID` / 400 at compile, where
7-
* the author still holds the document.
5+
* measures over a `currencyMode: 'record'` amount, `DATASET_INVALID` / 400 at
6+
* compile, where the author still holds the document. Both judge the record
7+
* mode alone, a surface that ships with them, so neither refuses anything a
8+
* dataset could write before.
89
*
9-
* 1. **A measure `currency` that contradicts its field.** The column's
10-
* currency chain lets an explicit measure `currency` win over everything
11-
* (`enrichResultColumns`), so `currency: 'USD'` over a field fixed to `EUR`
12-
* printed euros as dollars (probe A, `m_eur_as_usd`: 100 `"USD"`). Refused
13-
* when the field is `fixed` to a different code, and over every
14-
* `record`-mode field, whose currency is per row and so is contradicted by
15-
* any one code. The same code as a `fixed` field's is redundant, not
16-
* wrong, and is accepted. A `dynamic` field is not judged: its currency is
17-
* the tenant's, which is not known at compile.
18-
* 2. **A derived measure across differing currencies.** `computeDerived` adds,
19-
* subtracts, multiplies and divides bare numbers, so `sum` over a USD
20-
* measure and an EUR measure answered `200` with no currency at all (probe
21-
* A, `m_mixed_sum`). Refused when two of its operands are monetary and
22-
* their resolved currencies provably differ, whatever the op: a ratio or a
23-
* product of two amounts in two currencies is no more a number than their
24-
* sum. Non-monetary operands (a `count`) are not compared, so revenue per
25-
* deal stays legal.
10+
* 1. **A measure `currency` over a record-mode amount.** Its currency is per
11+
* row (ADR-0144 D2), so any one code contradicts it, and the column's chain
12+
* would let the measure's code win (`enrichResultColumns`).
13+
* 2. **A derived measure that combines a record-mode amount with an operand in
14+
* another currency.** `computeDerived` adds, subtracts, multiplies and
15+
* divides bare numbers, so such a value is not a number in any currency
16+
* (probe A of the #8345 measurement round, `m_mixed_sum`: `200` with no
17+
* currency). Refused, whatever the op, when a record-mode operand and another
18+
* monetary operand provably differ: another code field, a `fixed` code, an
19+
* explicit measure code or the tenant's. Non-monetary operands (a `count`)
20+
* are not compared, so an amount per invoice stays legal.
21+
*
22+
* ⛔ Not judged: the same two shapes over `fixed` / `dynamic` fields only — a
23+
* measure `currency` contradicting a `fixed` field, a derived measure over two
24+
* different `fixed` codes. Refusing those would narrow what compiles today,
25+
* which neither ruling #27119 nor ADR-0144 decides; it is an ADR-0144 open item
26+
* for the maintainer.
2627
*
2728
* ## The resolved currency, at compile
2829
*
2930
* The column's chain, with the request's half left symbolic: an explicit
3031
* measure `currency`, else the field's `fixed` code, else the tenant's (one
31-
* code per request, unknown here). A `record`-mode amount resolves to its code
32-
* field. Two currencies provably differ when they are two codes, two different
33-
* code fields, or a code field against anything else; the tenant's against a
34-
* code is NOT provably different and is not judged ("cannot answer, do not
35-
* block"). A derived `sum` / `difference` resolves to its operands' common
36-
* currency, so a derived measure built on one is judged too; a `ratio` or
37-
* `product` resolves to none.
32+
* code per request, unknown here). A record-mode amount resolves to its code
33+
* field. A derived `sum` / `difference` resolves to its operands' common
34+
* currency (none when they differ), so a derived measure built on one is
35+
* judged too; a `ratio` or `product` resolves to none.
3836
*
3937
* ## Not judged
4038
*
41-
* - No `declaredCurrency` hook (no data engine), or a field it cannot resolve:
42-
* only an explicit measure `currency` is known then.
39+
* - No `declaredCurrency` hook (no data engine), or a field it cannot resolve.
4340
* - A relationship-path field (`account.amount`): the hook answers for the
4441
* base object, as for the aggregate × field-type door.
4542
* - A duration measure, which counts time, not money.
@@ -70,13 +67,18 @@ const currencyWords = (r: Resolved): string =>
7067
? `each record's '${r.codeField}' (record currency)`
7168
: "the tenant's default currency";
7269

70+
/** Two currencies that are provably not one: two codes, two code fields, or a code field and anything else. */
7371
function provablyDiffer(a: Resolved, b: Resolved): boolean {
7472
if (a.kind === 'record' || b.kind === 'record') {
7573
return !(a.kind === 'record' && b.kind === 'record' && a.codeField === b.codeField);
7674
}
7775
return a.kind === 'code' && b.kind === 'code' && a.code !== b.code;
7876
}
7977

78+
/** The pair guard 2 refuses: a provable difference with a record-mode amount on one side. */
79+
const recordPairDiffers = (a: Resolved, b: Resolved): boolean =>
80+
(a.kind === 'record' || b.kind === 'record') && provablyDiffer(a, b);
81+
8082
/**
8183
* Refuse the first measure of `dataset` that breaks either guard — see the
8284
* module header. `declaredCurrency` answers for a bare field of the base
@@ -90,7 +92,7 @@ export function assertMeasureCurrenciesCoherent(
9092
const fieldOf = (m: DatasetMeasure): DeclaredCurrency | undefined =>
9193
m.field && !m.field.includes('.') ? declaredCurrency?.(dataset.object, m.field) : undefined;
9294

93-
// Guard 1 — a measure `currency` that contradicts its field.
95+
// Guard 1 — a measure `currency` over a record-mode amount.
9496
for (const m of dataset.measures) {
9597
if (m.derived || m.duration || !m.currency || !m.field) continue;
9698
const field = fieldOf(m);
@@ -103,17 +105,9 @@ export function assertMeasureCurrenciesCoherent(
103105
+ 'row carries its own.',
104106
);
105107
}
106-
if (field?.defaultCurrency !== undefined && field.defaultCurrency !== m.currency) {
107-
throw datasetInvalidError(
108-
`[dataset-compiler] dataset "${dataset.name}" measure "${m.name}" declares currency "${m.currency}" `
109-
+ `over field "${m.field}", which object "${dataset.object}" fixes to ${field.defaultCurrency}. The `
110-
+ `amounts are ${field.defaultCurrency}, so the column would print them as ${m.currency}: delete the `
111-
+ `measure's \`currency\` (the column takes the field's ${field.defaultCurrency}).`,
112-
);
113-
}
114108
}
115109

116-
// Guard 2 — a derived measure across differing currencies.
110+
// Guard 2 — a derived measure combining a record-mode amount with another currency.
117111
const memo = new Map<string, Resolved | undefined>();
118112
const resolving = new Set<string>();
119113
const resolvedOf = (m: DatasetMeasure): Resolved | undefined => {
@@ -148,9 +142,10 @@ export function assertMeasureCurrenciesCoherent(
148142
for (const m of dataset.measures) {
149143
if (!m.derived) continue;
150144
const operands = monetaryOperands(m);
151-
const first = operands[0];
152-
const other = operands.find((o) => first && provablyDiffer(o.resolved, first.resolved));
153-
if (!first || !other) continue;
145+
const pair = operands.flatMap((a, i) => operands.slice(i + 1).map((b) => [a, b] as const))
146+
.find(([a, b]) => recordPairDiffers(a.resolved, b.resolved));
147+
if (!pair) continue;
148+
const [first, other] = pair;
154149
throw datasetInvalidError(
155150
`[dataset-compiler] dataset "${dataset.name}" derived measure "${m.name}" (${m.derived.op}) combines `
156151
+ `measure "${first.name}", in ${currencyWords(first.resolved)}, with measure "${other.name}", in `

0 commit comments

Comments
 (0)