From c053f4cabaa4929a80aabba4320f406846a74d12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:41:46 +0000 Subject: [PATCH] fix(service-analytics): a dataset refuses a measure currency contradicting its field's fixed code, and a derived measure across two different fixed codes Both compile-time guards in measure-currency-coherence.ts gain their fixed arm (ADR-0144 open item 12, ruled letter A). The two chain pins move off the contradicting fixed shape: currency-mode-relay restates the fixed code, and query-dataset's chain pin moves to a dynamic field. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cyL63KrU8pu6fycLHW --- .../27579-fixed-currency-measure-guards.md | 19 ++++ .../src/__tests__/currency-mode-relay.test.ts | 14 +-- .../src/__tests__/query-dataset.test.ts | 7 +- .../record-currency-analytics.test.ts | 84 +++++++++++++-- .../src/measure-currency-coherence.ts | 102 +++++++++++------- 5 files changed, 172 insertions(+), 54 deletions(-) create mode 100644 .changeset/27579-fixed-currency-measure-guards.md diff --git a/.changeset/27579-fixed-currency-measure-guards.md b/.changeset/27579-fixed-currency-measure-guards.md new file mode 100644 index 00000000000..a9727d61926 --- /dev/null +++ b/.changeset/27579-fixed-currency-measure-guards.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-analytics': minor +--- + +fix(service-analytics): a dataset refuses a measure `currency` that contradicts its field's `fixed` code, and a derived measure across two different `fixed` codes (ADR-0144 open item 12) + +Clause-②: yes (narrowing) +Metadata-protocol: narrowed + +**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. Both reach boot registration, `queryDataset` and the Studio draft preview, which compile through the one `compileDataset` call. + +- A measure `currency` whose code differs from the aggregated field's `fixed` code. The column printed the field's amounts under the measure's code, such as euros labelled `USD`. The message names the fix: delete the measure's `currency`, and the column takes the field's code. A measure `currency` that restates the `fixed` code is still accepted. +- A derived measure, for every op, whose monetary operands resolve to two different `fixed` codes, directly or through a derived `sum` / `difference` built on them. The value combined bare numbers in two currencies and carried no currency at all. The message names the fix: derive it from measures in one currency, or drop the operand in the other. A `count` operand is not compared, so an amount per invoice still compiles. + +Not judged, unchanged: a `fixed` code against the tenant currency, which is not known at compile; a measure `currency` over a `dynamic` field. The record-mode arms of both guards are unchanged. + +No in-tree dataset declares either shape. A deployed tenant's stored datasets cannot be surveyed from the repository: a stored dataset with either shape now refuses at registration and at query, and its message names the edit. + + diff --git a/packages/services/service-analytics/src/__tests__/currency-mode-relay.test.ts b/packages/services/service-analytics/src/__tests__/currency-mode-relay.test.ts index 5ec33abe70e..a98002f2d0c 100644 --- a/packages/services/service-analytics/src/__tests__/currency-mode-relay.test.ts +++ b/packages/services/service-analytics/src/__tests__/currency-mode-relay.test.ts @@ -98,10 +98,12 @@ const DATASET = DatasetSchema.parse({ { name: 'm_nomode', aggregate: 'sum', field: 'amt_nomode' }, { name: 'm_emptycfg', aggregate: 'sum', field: 'amt_emptycfg' }, { name: 'm_bare', aggregate: 'sum', field: 'amt_bare' }, - // An explicit measure `currency` wins over every field-side answer, - // the field's FIXED currency included. + // An explicit measure `currency` wins over a dynamic field's tenant + // default. [ADR-0144 M0-3] Over a FIXED field it may only restate the + // field's code: a contradicting one is refused at compile + // (`measure-currency-coherence.ts`, record-currency-analytics.test.ts). { name: 'm_explicit_dynamic', aggregate: 'sum', field: 'amt_dynamic', currency: 'JPY' }, - { name: 'm_explicit_fixed', aggregate: 'sum', field: 'amt_fixed', currency: 'JPY' }, + { name: 'm_explicit_fixed', aggregate: 'sum', field: 'amt_fixed', currency: 'EUR' }, { name: 'm_units', aggregate: 'sum', field: 'units' }, ], }); @@ -220,11 +222,11 @@ describe.each(['raw', 'parsed'])('[#20091] result-column currency honours expect(preview.m_bare).toBe('USD'); }); - it('an explicit measure currency wins, over a dynamic field and over a fixed one', () => { + it("an explicit measure currency wins over a dynamic field, and restates a fixed one's code", () => { expect(live.m_explicit_dynamic).toBe('JPY'); expect(preview.m_explicit_dynamic).toBe('JPY'); - expect(live.m_explicit_fixed).toBe('JPY'); - expect(preview.m_explicit_fixed).toBe('JPY'); + expect(live.m_explicit_fixed).toBe('EUR'); + expect(preview.m_explicit_fixed).toBe('EUR'); }); it('CONTROL: a non-monetary measure carries no currency', () => { diff --git a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts index eab855be937..582c9bb1f85 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts @@ -153,8 +153,11 @@ describe('AnalyticsService.queryDataset', () => { expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('GBP'); }); - it('chain: an explicit measure currency wins over the field default and the tenant ctx', async () => { - const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency', defaultCurrency: 'EUR' } : undefined); + // [ADR-0144 M0-3] Over a FIXED field a measure currency may only restate the + // field's code — a contradicting one is refused at compile + // (`measure-currency-coherence.ts`, pinned in record-currency-analytics.test.ts). + it('chain: an explicit measure currency wins over the tenant ctx on a dynamic field', async () => { + const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency' } : undefined); const r = await svc.queryDataset(moneyDataset({ currency: 'JPY' }), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'GBP' } as ExecutionContext) as any; expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('JPY'); }); diff --git a/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts b/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts index 1e6b88348a1..a70812a0445 100644 --- a/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts +++ b/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts @@ -19,11 +19,12 @@ * own capabilities; `ObjectQLStrategy`, narrowed to the engine aggregate), * and the column on the draft-preview path, which shares the one * column-description seam. - * - The two compile-time guards (`measure-currency-coherence.ts`), both on - * the record mode alone: `DATASET_INVALID` / 400 for a measure `currency` - * over a record-mode amount, and for a derived measure combining one with an - * operand in another currency. The same shapes over `fixed` fields only - * still compile (controls below). + * - The two compile-time guards (`measure-currency-coherence.ts`): + * `DATASET_INVALID` / 400 for a measure `currency` over a record-mode amount + * or contradicting a `fixed` field's code, and for a derived measure + * combining a record-mode amount with another currency or two different + * `fixed` codes. The `fixed` arms reach boot registration, `queryDataset` + * and the draft preview alike (ruling 6486427200 on #8345). * * The SQLite cell runs everywhere; the strategies are the axis here, not the * dialect (the door stands ahead of both and reads nothing). @@ -315,11 +316,20 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { ); }); - it('CONTROL: a measure currency over a fixed field is not judged, whatever its code (ADR-0144 open item 12, its own card after R1)', () => { - expect(compileRefusal([{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }])).toBeUndefined(); + it("a measure currency contradicting a fixed field's code is refused, naming the fix", () => { + expectRefused([{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }], 'eur_as_jpy'); + expect(compileRefusal([{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }])?.message) + .toContain("delete the measure's `currency`"); + }); + + it("CONTROL: a measure currency restating a fixed field's code is accepted", () => { expect(compileRefusal([{ name: 'eur', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' }])).toBeUndefined(); }); + it('CONTROL: a measure currency over a dynamic field is not judged (the tenant currency is unknown at compile)', () => { + expect(compileRefusal([{ name: 'tenant_as_usd', aggregate: 'sum', field: 'amt_tenant', currency: 'USD' }])).toBeUndefined(); + }); + const base = [ { name: 'usd', aggregate: 'sum', field: 'amt_usd' }, { name: 'eur', aggregate: 'sum', field: 'amt_eur' }, @@ -345,8 +355,31 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { ); }); - it('CONTROL: a derived measure over two different fixed codes is not judged (ADR-0144 open item 12, its own card after R1)', () => { - expect(compileRefusal([...base, { name: 'mixed', derived: { op: 'sum', of: ['usd', 'eur'] } }])).toBeUndefined(); + for (const op of ['sum', 'difference', 'ratio', 'product'] as const) { + it(`a derived ${op} across two different fixed codes is refused, naming the fix`, () => { + expectRefused([...base, { name: 'mixed', derived: { op, of: ['usd', 'eur'] } }], 'mixed'); + expect(compileRefusal([...base, { name: 'mixed', derived: { op, of: ['usd', 'eur'] } }])?.message) + .toContain('derive it from measures in one currency, or drop the operand in the other'); + }); + } + + it('a derived measure built on a fixed-code derived sum is judged at the next level', () => { + expectRefused( + [...base, { name: 'usd_twice', derived: { op: 'sum', of: ['usd', 'usd'] } }, { name: 'mixed', derived: { op: 'sum', of: ['usd_twice', 'eur'] } }], + 'mixed', + ); + }); + + it('CONTROL: a fixed operand restated by its measure currency compares by that one code', () => { + expect( + compileRefusal([...base, { name: 'usd_restated', aggregate: 'sum', field: 'amt_usd', currency: 'USD' }, { name: 'both', derived: { op: 'sum', of: ['usd', 'usd_restated'] } }]), + ).toBeUndefined(); + }); + + it('CONTROL: an explicit measure currency over a dynamic field is not a fixed code, so a derived measure over it and a fixed one is not judged', () => { + expect( + compileRefusal([...base, { name: 'tenant_as_jpy', aggregate: 'sum', field: 'amt_tenant', currency: 'JPY' }, { name: 'mixed', derived: { op: 'sum', of: ['tenant_as_jpy', 'usd'] } }]), + ).toBeUndefined(); }); it('accepted: an amount per invoice, an amount with itself, a fixed amount with the tenant currency', () => { @@ -354,10 +387,43 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { compileRefusal([ ...base, { name: 'per_invoice', derived: { op: 'ratio', of: ['total', 'invoices'] } }, + { name: 'usd_per_invoice', derived: { op: 'ratio', of: ['usd', 'invoices'] } }, + { name: 'eur_per_invoice', derived: { op: 'ratio', of: ['eur', 'invoices'] } }, { name: 'usd_and_tenant', derived: { op: 'sum', of: ['usd', 'tenant'] } }, { name: 'total_share', derived: { op: 'ratio', of: ['total', 'total'] } }, ]), ).toBeUndefined(); }); + + // The request door and the draft preview compile through the same + // `compileDataset` call as registration, so both fixed shapes refuse there too. + const fixedShapes = [ + ['a measure currency contradicting a fixed field', [{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }], 'eur_as_jpy'], + ['a derived measure across two different fixed codes', [...base, { name: 'mixed', derived: { op: 'difference', of: ['usd', 'eur'] } }], 'mixed'], + ] as const; + for (const [label, measures, named] of fixedShapes) { + for (const [door, options] of [['queryDataset', undefined], ['the draft preview', { previewDrafts: true }]] as const) { + it(`${door} refuses ${label}`, async () => { + const err = await refusalOf( + services.objectql!.queryDataset(datasetWith([...measures]), { measures: [named] }, CTX, options as never), + ); + expect(err.code, err.message).toBe('DATASET_INVALID'); + expect(err.status).toBe(400); + expect(err.message).toContain(`"${named}"`); + }); + } + } + + it(`CONTROL: queryDataset and the draft preview serve a measure currency restating a fixed field's code`, async () => { + for (const options of [undefined, { previewDrafts: true }]) { + const result = await services.objectql!.queryDataset( + datasetWith([{ name: 'eur', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' }]), + { measures: ['eur'] }, + CTX, + options as never, + ); + expect(column(result, 'eur')?.currency).toBe('EUR'); + } + }); }); }); diff --git a/packages/services/service-analytics/src/measure-currency-coherence.ts b/packages/services/service-analytics/src/measure-currency-coherence.ts index 72f88655fa4..54b80f02b19 100644 --- a/packages/services/service-analytics/src/measure-currency-coherence.ts +++ b/packages/services/service-analytics/src/measure-currency-coherence.ts @@ -2,41 +2,48 @@ /** * [ADR-0144, multi-currency M0-3] Two compile-time guards on a dataset's - * measures over a `currencyMode: 'record'` amount, `DATASET_INVALID` / 400 at - * compile, where the author still holds the document. Both judge the record - * mode alone, a surface that ships with them, so neither refuses anything a - * dataset could write before. + * measure currencies, `DATASET_INVALID` / 400 at compile, where the author + * still holds the document. Each has a record-mode arm and a `fixed` arm; the + * `fixed` arms narrow what compiled before, under ADR-0144 open item 12 + * (ruling 6486427200 on #8345, letter A). * - * 1. **A measure `currency` over a record-mode amount.** Its currency is per - * row (ADR-0144 D2), so any one code contradicts it, and the column's chain - * would let the measure's code win (`enrichResultColumns`). That holds for - * a derived measure whose resolved currency is the record mode too (a sum - * of record-mode amounts over one code field): its column carries no - * per-row pointer, so the one code would label every row. - * 2. **A derived measure that combines a record-mode amount with an operand in - * another currency.** `computeDerived` adds, subtracts, multiplies and - * divides bare numbers, so such a value is not a number in any currency - * (probe A of the #8345 measurement round, `m_mixed_sum`: `200` with no - * currency). Refused, whatever the op, when a record-mode operand and another - * monetary operand provably differ: another code field, a `fixed` code, an - * explicit measure code or the tenant's. Non-monetary operands (a `count`) - * are not compared, so an amount per invoice stays legal. + * 1. **A measure `currency` that contradicts its field.** The column's chain + * lets the measure's code win (`enrichResultColumns`), so `currency: 'USD'` + * over a field fixed to `EUR` printed euros as dollars (probe A of the + * #8345 measurement round, `m_eur_as_usd`: 100 `"USD"`). + * - Over a record-mode amount, any one code: its currency is per row + * (ADR-0144 D2). That holds for a derived measure whose resolved currency + * is the record mode too (a sum of record-mode amounts over one code + * field): its column carries no per-row pointer, so the one code would + * label every row. + * - Over a `fixed` field, a code other than the field's. The same code is + * redundant, not wrong, and is accepted. + * 2. **A derived measure across currencies.** `computeDerived` adds, + * subtracts, multiplies and divides bare numbers, so such a value is not a + * number in any currency (probe A, `m_mixed_sum`: `200` with no currency). + * Refused, whatever the op, when two monetary operands provably differ and + * - one is a record-mode amount, and the other is another code field, a + * code or the tenant's; or + * - both resolve to `fixed` codes, and the codes differ. + * Non-monetary operands (a `count`) are not compared, so an amount per + * invoice stays legal. * - * ⛔ Not judged here: the same two shapes over `fixed` fields only — a - * measure `currency` contradicting a `fixed` field, a derived measure over two - * different `fixed` codes. Refusing those narrows what compiles today, so - * ADR-0144 open item 12 was ruled (6486427200 on #8345) to refuse them on a - * card of its own after R1. A measure `currency` over a `dynamic` field stays - * legal under either. + * ⛔ Not judged (the ruling's own list): a `fixed` code against the tenant's + * currency, which is not known at compile ("cannot answer, do not block"); a + * measure `currency` over a `dynamic` field, the top of the #27119 Q2 X chain. + * An operand whose code is such an explicit one is not a `fixed` code, so + * guard 2 does not compare it with one either. * * ## The resolved currency, at compile * * The column's chain, with the request's half left symbolic: an explicit * measure `currency`, else the field's `fixed` code, else the tenant's (one - * code per request, unknown here). A record-mode amount resolves to its code + * code per request, unknown here). A code is `fixed` when it is the field's + * `fixed` code (restated or not). A record-mode amount resolves to its code * field. A derived `sum` / `difference` resolves to its operands' common - * currency (none when they differ), so a derived measure built on one is - * judged too; a `ratio` or `product` resolves to none. + * currency (none when they differ; `fixed` when every operand's is), so a + * derived measure built on one is judged too; a `ratio` or `product` + * resolves to none. * * ## Not judged * @@ -60,7 +67,7 @@ export interface DeclaredCurrency { } type Resolved = - | { readonly kind: 'code'; readonly code: string } + | { readonly kind: 'code'; readonly code: string; readonly fixed: boolean } | { readonly kind: 'record'; readonly codeField: string } | { readonly kind: 'tenant' }; @@ -79,9 +86,13 @@ function provablyDiffer(a: Resolved, b: Resolved): boolean { return a.kind === 'code' && b.kind === 'code' && a.code !== b.code; } -/** The pair guard 2 refuses: a provable difference with a record-mode amount on one side. */ -const recordPairDiffers = (a: Resolved, b: Resolved): boolean => - (a.kind === 'record' || b.kind === 'record') && provablyDiffer(a, b); +/** + * The pairs guard 2 refuses: a provable difference with a record-mode amount + * on one side, or two different `fixed` codes. + */ +const pairRefused = (a: Resolved, b: Resolved): boolean => + ((a.kind === 'record' || b.kind === 'record') && provablyDiffer(a, b)) + || (a.kind === 'code' && a.fixed && b.kind === 'code' && b.fixed && a.code !== b.code); /** * Refuse the first measure of `dataset` that breaks either guard — see the @@ -108,13 +119,20 @@ export function assertMeasureCurrenciesCoherent( const common = (m.derived.op === 'sum' || m.derived.op === 'difference') ? monetaryOperands(m).map((o) => o.resolved) : []; - r = common.length > 0 && common.every((c) => !provablyDiffer(c, common[0]!)) ? common[0] : undefined; + const first = common[0]; + if (first && common.every((c) => !provablyDiffer(c, first))) { + r = first.kind === 'code' + ? { ...first, fixed: common.every((c) => c.kind === 'code' && c.fixed) } + : first; + } } else if (!m.duration) { const field = fieldOf(m); if (field?.currencyField !== undefined) r = { kind: 'record', codeField: field.currencyField }; - else if (m.currency) r = { kind: 'code', code: m.currency }; + else if (m.currency) r = { kind: 'code', code: m.currency, fixed: field?.defaultCurrency === m.currency }; else if (field?.type === 'currency') { - r = field.defaultCurrency !== undefined ? { kind: 'code', code: field.defaultCurrency } : { kind: 'tenant' }; + r = field.defaultCurrency !== undefined + ? { kind: 'code', code: field.defaultCurrency, fixed: true } + : { kind: 'tenant' }; } } resolving.delete(m.name); @@ -128,7 +146,8 @@ export function assertMeasureCurrenciesCoherent( return resolved ? [{ name, resolved }] : []; }); - // Guard 1 — a measure `currency` over a record-mode amount, aggregated or derived. + // Guard 1 — a measure `currency` over a record-mode amount (aggregated or + // derived), or contradicting a `fixed` field's code. for (const m of dataset.measures) { if (m.duration || !m.currency) continue; const field = m.derived ? undefined : fieldOf(m); @@ -141,6 +160,14 @@ export function assertMeasureCurrenciesCoherent( + 'row carries its own.', ); } + if (field?.defaultCurrency !== undefined && field.defaultCurrency !== m.currency) { + throw datasetInvalidError( + `[dataset-compiler] dataset "${dataset.name}" measure "${m.name}" declares currency "${m.currency}" ` + + `over field "${m.field}", which object "${dataset.object}" fixes to ${field.defaultCurrency}. The ` + + `amounts are ${field.defaultCurrency}, so the column would print them as ${m.currency}: delete the ` + + `measure's \`currency\` (the column takes the field's ${field.defaultCurrency}).`, + ); + } const resolved = m.derived ? resolvedOf(m) : undefined; if (resolved?.kind === 'record') { throw datasetInvalidError( @@ -151,12 +178,13 @@ export function assertMeasureCurrenciesCoherent( } } - // Guard 2 — a derived measure combining a record-mode amount with another currency. + // Guard 2 — a derived measure combining a record-mode amount with another + // currency, or two different `fixed` codes. for (const m of dataset.measures) { if (!m.derived) continue; const operands = monetaryOperands(m); const pair = operands.flatMap((a, i) => operands.slice(i + 1).map((b) => [a, b] as const)) - .find(([a, b]) => recordPairDiffers(a.resolved, b.resolved)); + .find(([a, b]) => pairRefused(a.resolved, b.resolved)); if (!pair) continue; const [first, other] = pair; throw datasetInvalidError(