Skip to content

Commit ba4ecf1

Browse files
fix(service-analytics): refuse a derived measure currency that contradicts its operands' fixed code (#28043)
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL Fixes #28019 ## 维护者速读 - 改了什么:数据集编译时,派生度量(derived sum / difference)若自行声明的 `currency` 与其操作数统一解析出的 `fixed` 币种不同,直接拒绝(`DATASET_INVALID` / 400),报错写明改法;重述同一币种仍合法。 - 为什么改:守卫 1 原先跳过派生度量,两个 EUR 固定币种的度量求和后声明 `currency: 'USD'` 能编译通过,结果列把欧元金额标成美元,没有任何提示(#28019)。 - 风险与回滚:收窄,`Clause-②: yes (narrowing)`、`Metadata-protocol: narrowed`、`minor`;此前能编译的这类数据集现在被拒,仓内没有数据集用到该形态,已部署租户若有,报错会点名要删的 `currency`。回滚即还原本 PR。 - 先看:`packages/services/service-analytics/src/measure-currency-coherence.ts` 守卫 1 末尾新增的 fixed 判断,`src/__tests__/record-currency-analytics.test.ts` 新增的固定测试。 - 两分钟验证:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/record-currency-analytics.test.ts`,49 例全绿;修复前其中 5 例红。 - 需要你做的:无(契约复审由席位在本 head 上做,席位按本任落地授权落地)。 Clause-②: yes (narrowing) — a dataset that declared a derived measure's `currency` against its operands' single `fixed` code compiled before and is refused now Metadata-protocol: narrowed ## What changes - `measure-currency-coherence.ts`, Guard 1: a derived measure that declares a `currency` is now also compared with its resolved currency (`resolvedOf`). When every monetary operand resolves to one `fixed` code and the declared code differs, compile refuses with `DATASET_INVALID` / 400. The sentence follows the existing fixed-field refusal: it names the dataset, the derived measure, its operands, the operands' code, and the fix (delete the measure's `currency`; the column takes the operands' code). Operands reached through a fixed-code derived sum are covered too. - Unchanged: a derived measure that restates its operands' `fixed` code; a derived measure whose operands resolve to no one `fixed` code (a `fixed` amount with a tenant-currency one, a `ratio` / `product`); the record-mode refusal on derived measures; Guard 2. - The module header and Guard 1's comment describe the derived arm. - One `minor` changeset for `@objectstack/service-analytics` (a dataset value is refused, so `Metadata-protocol: narrowed`, as in #27579's changeset for the sibling refusals), with the BREAKING note and the ADR-0087 `not-required (no-migration-prescription)` disposition. ## Why `{ derived: { op: 'sum', of: ['eur_a', 'eur_b'] }, currency: 'USD' }` over two EUR-fixed measures compiled, and `enrichResultColumns` let the declared `USD` label a column of euros. Guard 1 skipped derived measures for the fixed comparison (`const field = m.derived ? undefined : fieldOf(m)`), although `resolvedOf` already knew the operands' common `fixed` code. Triage (6518908209) ruled that Guard 1 judges derived measures too and that restating the same code stays legal. ## Verification - Red first: on the unfixed tree `record-currency-analytics.test.ts` gives 5 failed / 44 passed. The 5 are the derived sum and difference pins, the fixed-code derived sum pin, and the `queryDataset` and draft-preview door pins; the controls (restated code, no one fixed code) stay green. - Ablation: with the new condition replaced by `false`, the same 5 go red; restored, the blob equals HEAD's and `git diff HEAD` is empty. - `pnpm --filter @objectstack/service-analytics test`: 196 files, 4759 passed / 282 skipped. `typecheck` exits 0. eslint on both changed files exits 0. - `dispatch-gates --commands`: 59 derived, 58 run green, 1 not measured locally (`check:dual-build-cjs-loads` needs a whole-tree build), 0 unrun. Dogfood battery: 357 files / 2875 tests passed, 0 failures. - The declaration lines were moved from the claim's first `no (narrowing)` / `none` / `patch` to `yes (narrowing)` / `narrowed` / `minor` before the push (claim amended); the gates above ran on the same code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5f31470 commit ba4ecf1

3 files changed

Lines changed: 79 additions & 3 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
---
4+
5+
fix(service-analytics): a dataset refuses a derived measure `currency` that contradicts the one `fixed` code its operands resolve to
6+
7+
Clause-②: no (narrowing) — not a widening; a dataset that compiled before is refused now
8+
Metadata-protocol: narrowed
9+
10+
**BREAKING** (`@objectstack/service-analytics`): one more compile-time refusal, `DATASET_INVALID` / 400, for dataset metadata that compiled before and printed a wrong label. It reaches boot registration, `queryDataset` and the Studio draft preview, which compile through the one `compileDataset` call.
11+
12+
- A derived `sum` / `difference` whose monetary operands all resolve to one `fixed` code, directly or through a derived measure built on them, and which declares its own `currency` with another code. The column printed the operands' amounts under the declared code, such as `{ derived: { op: 'sum', of: ['eur_a', 'eur_b'] }, currency: 'USD' }` over two EUR-fixed measures printing euros labelled `USD`. The message names the dataset, the measure, its operands and their code, and the fix: restate the operands' code as the measure's `currency`, or delete it (the column then carries no currency code; a derived column takes no code from its operands on the wire).
13+
- A derived measure `currency` that restates the operands' `fixed` code is still accepted.
14+
15+
Not judged, unchanged: a derived measure whose operands resolve to no one `fixed` code (a `fixed` amount with a tenant-currency one, a `ratio` or `product`), and the record-mode and mixed-code refusals of both guards.
16+
17+
No in-tree dataset declares this shape. A deployed tenant's stored dataset with it now refuses at registration and at query, and its message names the edit.
18+
19+
<!-- adr-0087: not-required (no-migration-prescription) the refusal rejects a contradiction in a dataset's own measures at compile and names the fix in its message; there is no stored shape to convert and no key is removed -->

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
* column-description seam.
2222
* - The two compile-time guards (`measure-currency-coherence.ts`):
2323
* `DATASET_INVALID` / 400 for a measure `currency` over a record-mode amount
24-
* or contradicting a `fixed` field's code, and for a derived measure
24+
* or contradicting a `fixed` field's code or a derived measure's operands'
25+
* one `fixed` code, and for a derived measure
2526
* combining a record-mode amount with another currency or two different
2627
* `fixed` codes. The `fixed` arms reach boot registration, `queryDataset`
2728
* and the draft preview alike (ruling 6486427200 on #8345).
@@ -370,6 +371,48 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
370371
);
371372
});
372373

374+
for (const op of ['sum', 'difference'] as const) {
375+
it(`a derived ${op} declaring a currency other than its operands' one fixed code is refused, naming the fix`, () => {
376+
const measures = [
377+
...base,
378+
{ name: 'eur_restated', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' },
379+
{ name: 'eur_as_usd', derived: { op, of: ['eur', 'eur_restated'] }, currency: 'USD' },
380+
];
381+
expectRefused(measures, 'eur_as_usd');
382+
expect(compileRefusal(measures)?.message).toContain(
383+
`[dataset-compiler] dataset "os27187_guard" derived measure "eur_as_usd" declares currency "USD" over `
384+
+ 'measures "eur", "eur_restated", whose amounts are fixed to EUR.',
385+
);
386+
expect(compileRefusal(measures)?.message)
387+
.toContain("restate the operands' EUR as the measure's `currency`, or delete it (the column then carries no currency code)");
388+
});
389+
}
390+
391+
it('a derived measure declaring a currency over a fixed-code derived sum is judged too', () => {
392+
expectRefused(
393+
[...base, { name: 'eur_twice', derived: { op: 'sum', of: ['eur', 'eur'] } }, { name: 'eur_as_jpy', derived: { op: 'sum', of: ['eur_twice', 'eur'] }, currency: 'JPY' }],
394+
'eur_as_jpy',
395+
);
396+
});
397+
398+
it("CONTROL: a derived measure restating its operands' one fixed code is accepted", () => {
399+
expect(
400+
compileRefusal([...base, { name: 'eur_twice', derived: { op: 'sum', of: ['eur', 'eur'] }, currency: 'EUR' }]),
401+
).toBeUndefined();
402+
});
403+
404+
it('CONTROL: a derived measure currency is not judged where its operands resolve to no one fixed code', () => {
405+
expect(
406+
compileRefusal([
407+
...base,
408+
// a fixed amount with the tenant currency: one currency, but not a `fixed` one
409+
{ name: 'usd_and_tenant_as_jpy', derived: { op: 'sum', of: ['usd', 'tenant'] }, currency: 'JPY' },
410+
// a ratio resolves to no currency
411+
{ name: 'eur_per_invoice_as_usd', derived: { op: 'ratio', of: ['eur', 'invoices'] }, currency: 'USD' },
412+
]),
413+
).toBeUndefined();
414+
});
415+
373416
it('CONTROL: a fixed operand restated by its measure currency compares by that one code', () => {
374417
expect(
375418
compileRefusal([...base, { name: 'usd_restated', aggregate: 'sum', field: 'amt_usd', currency: 'USD' }, { name: 'both', derived: { op: 'sum', of: ['usd', 'usd_restated'] } }]),
@@ -400,6 +443,7 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
400443
const fixedShapes = [
401444
['a measure currency contradicting a fixed field', [{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }], 'eur_as_jpy'],
402445
['a derived measure across two different fixed codes', [...base, { name: 'mixed', derived: { op: 'difference', of: ['usd', 'eur'] } }], 'mixed'],
446+
["a derived measure currency contradicting its operands' fixed code", [...base, { name: 'eur_as_usd', derived: { op: 'sum', of: ['eur', 'eur'] }, currency: 'USD' }], 'eur_as_usd'],
403447
] as const;
404448
for (const [label, measures, named] of fixedShapes) {
405449
for (const [door, options] of [['queryDataset', undefined], ['the draft preview', { previewDrafts: true }]] as const) {

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
* field): its column carries no per-row pointer, so the one code would
1818
* label every row.
1919
* - Over a `fixed` field, a code other than the field's. The same code is
20-
* redundant, not wrong, and is accepted.
20+
* redundant, not wrong, and is accepted. That holds for a derived measure
21+
* whose resolved currency is one `fixed` code (a sum of `fixed` amounts in
22+
* one code): `computeDerived` keeps the operands' amounts, so the measure's
23+
* code would label them as another currency.
2124
* 2. **A derived measure across currencies.** `computeDerived` adds,
2225
* subtracts, multiplies and divides bare numbers, so such a value is not a
2326
* number in any currency (probe A, `m_mixed_sum`: `200` with no currency).
@@ -147,7 +150,8 @@ export function assertMeasureCurrenciesCoherent(
147150
});
148151

149152
// Guard 1 — a measure `currency` over a record-mode amount (aggregated or
150-
// derived), or contradicting a `fixed` field's code.
153+
// derived), or contradicting a `fixed` code (a field's, or the one its
154+
// derived operands resolve to).
151155
for (const m of dataset.measures) {
152156
if (m.duration || !m.currency) continue;
153157
const field = m.derived ? undefined : fieldOf(m);
@@ -176,6 +180,15 @@ export function assertMeasureCurrenciesCoherent(
176180
+ "currencies: delete the measure's `currency`.",
177181
);
178182
}
183+
if (resolved?.kind === 'code' && resolved.fixed && resolved.code !== m.currency) {
184+
const operands = [...new Set(monetaryOperands(m).map((o) => `"${o.name}"`))].join(', ');
185+
throw datasetInvalidError(
186+
`[dataset-compiler] dataset "${dataset.name}" derived measure "${m.name}" declares currency `
187+
+ `"${m.currency}" over measures ${operands}, whose amounts are fixed to ${resolved.code}. The amounts are `
188+
+ `${resolved.code}, so the column would print them as ${m.currency}: restate the operands' `
189+
+ `${resolved.code} as the measure's \`currency\`, or delete it (the column then carries no currency code).`,
190+
);
191+
}
179192
}
180193

181194
// Guard 2 — a derived measure combining a record-mode amount with another

0 commit comments

Comments
 (0)