Skip to content

Commit 6dc06b2

Browse files
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 (#27991)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D5QoSbzQb2Lqa8sG8mU6Fiz)_ Fixes #27579 Clause-②: yes (narrowing) Metadata-protocol: narrowed Skills-docs: follow-up owed — skills/objectstack-data/rules/field-types.md, skills/objectstack-ui/rules/dashboards.md, content/docs/data-modeling/currency.mdx, content/docs/api/data-api.mdx ## 维护者速读 - 改了什么:数据集编译时新增两条只针对 `fixed` 币种的拒收。度量写的 `currency` 和被汇总字段的 `fixed` 币种不同,拒收;派生度量(任何运算)的两个金额操作数落在两个不同的 `fixed` 币种,拒收。都返回 `DATASET_INVALID` / 400,并给出改法。 - 为什么改:这两种写法今天会算出不报错的错数(EUR 字段被标成 USD,或 USD 减 EUR 不带币种)。维护者裁决 6486427200(ADR-0144 Open item 12,字母 A)。 - 风险与回滚:这是收窄,以前能编译的这两种写法现在被拒。仓内数据集元数据实测 0 处命中;已部署租户存的数据集无法从仓库调查,它们会在注册与请求时被拒,错误信息写明改法。回滚即撤本 PR。 - 先看:`packages/services/service-analytics/src/measure-currency-coherence.ts`(两个 `fixed` 分支与 `fixed` 标记),`packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts`(三道门的 pin 与对照)。 - 两分钟验证:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/record-currency-analytics.test.ts`,预期 42 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes `assertMeasureCurrenciesCoherent` (`measure-currency-coherence.ts`), called once from `compileDataset`, gains the `fixed` arm of each guard. The record-mode arms from #27338 are unchanged. - **Shape 1.** An aggregated measure whose `currency` differs from its field's `fixed` code refuses with `DATASET_INVALID` / 400. The message names the measure, the field, the object and both codes, and the fix: delete the measure's `currency`, and the column takes the field's code. A `currency` that restates the `fixed` code is accepted. - **Shape 2.** A derived measure, for every op (`sum`, `difference`, `ratio`, `product`), whose monetary operands resolve to two different `fixed` codes refuses with `DATASET_INVALID` / 400, naming both operands, their codes and the fix (derive it from measures in one currency, or drop the operand in the other). A derived `sum` / `difference` over one `fixed` code resolves to that code, so a derived measure built on it is judged at the next level. A `count` operand is not compared. - **What a `fixed` code is.** A resolved code now carries `fixed`: true when it is the field's `fixed` code, restated by the measure or not. A derived `sum` / `difference` is `fixed` when every operand is. Shape 2 compares only `fixed` codes. - **Not judged, unchanged:** a `fixed` code against the tenant currency; a measure `currency` over a `dynamic` field. Because the ruling lists the second as legal, an operand whose code is such an explicit one is not a `fixed` code, and Shape 2 does not compare it (see Acceptance notes). - **Moved pins.** Starting from #27338's first draft (`80b98fb8`, dropped at `507ed8f9`): `currency-mode-relay.test.ts`'s `m_explicit_fixed` restates the fixed field's `EUR` instead of contradicting it with `JPY`, and `query-dataset.test.ts`'s chain pin moves to a `dynamic` field. - Changeset: `@objectstack/service-analytics` **minor**, with a BREAKING paragraph, both declaration lines, and the ADR-0087 marker `not-required (no-migration-prescription)`. ## Tests `record-currency-analytics.test.ts`, guard block (real engine over SQLite, through `AnalyticsServicePlugin`): - Shape 1 refused at registration, naming the fix; restated code accepted (control); a `currency` over a `dynamic` field not judged (control). - Shape 2 refused for each of the four ops, naming the fix; refused one level up through a derived `sum` of one `fixed` code; a `fixed` operand restated by its measure code compares by that one code (control); an explicit code over a `dynamic` field against a `fixed` one is not judged (control); amount per invoice over each `fixed` code, and a `fixed` amount with the tenant currency, accepted (controls). - The request door and the Studio draft preview: `queryDataset`, with and without `previewDrafts`, refuses both shapes with `DATASET_INVALID` / 400 naming the measure; a restated code is served with `currency: 'EUR'` on both (control). - The old "not judged" controls for the two `fixed` shapes are replaced by the pins above. ## Acceptance notes - **The in-tree survey: 0 of either shape.** I searched dataset metadata in `examples/**`, `packages/platform-objects/**`, `packages/runtime/src/seed-datasets.ts`, `ui/examples/**` and the skills evals. 16 files declare `measures:`, and none sets a measure `currency`. The two derived measures in the tree (`chart-gallery.dataset.ts`, `revenue-pulse.dataset.ts`) are both ratios of two counts. 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. - **One reading the ruling leaves open, for the contract review.** The ruling says Shape 2 compares operands that "resolve to two different `fixed` codes", and lists "a measure `currency` over a `dynamic` field" as legal. #27338's first draft compared any two resolved codes, so it also refused a derived measure over an explicit `JPY` on a `dynamic` field and a `fixed` `USD`. This PR follows the ruling's words: only `fixed` codes are compared, and a CONTROL pins that pair as accepted. Refusing that pair too would narrow further, and would need its own ruling. - **Skills and docs: follow-up owed.** `skills/objectstack-data/rules/field-types.md` says "Under `'fixed'`/`'dynamic'` a measure's own `currency` wins", which this PR makes false for a contradicting `fixed` code. `skills/objectstack-ui/rules/dashboards.md` (the currency chain comment), `content/docs/data-modeling/currency.mdx` (Roll-ups and datasets) and `content/docs/api/data-api.mdx` (the currency chain) describe the record-mode refusals only. None is in this card's file surface. Grep: `git grep -n -iE "measure.{0,40}currency|currency.{0,40}measure" -- 'skills/**' 'content/docs/**'`. - **`dataset-compiler.ts`.** The comment above the `assertMeasureCurrenciesCoherent` call names only the record-mode arms. It is outside the file surface and is incomplete rather than false, so this PR leaves it. - The ADR-0144 Open item 12 dated note stays with the epic PM, as the ruling says. ## Verification Head `c053f4ca` (base `bacb3e6a`). <details><summary>Red on the base, ablations, restore</summary> - The new and moved pins against the unchanged `measure-currency-coherence.ts` of `bacb3e6a`: 10 red, 83 green over the three files. Red: Shape 1 at registration, Shape 2 for each of the four ops, Shape 2 one level up, and both shapes at `queryDataset` and at the draft preview. Every control was green (restated code, `dynamic` field, `count` operand, tenant currency, explicit code over a `dynamic` field). - Through `scripts/ablation-replace.mjs` (anchor hit 1 → 0, restored blob == HEAD, `git diff HEAD` empty after each). The tests import the source, so no build was needed between legs. - Shape 1 arm disabled: 3 red (registration, `queryDataset`, preview), 39 green. - Shape 2 `fixed` pair disabled: 7 red (four ops, one level up, `queryDataset`, preview), 35 green. - An explicit code over a `dynamic` field marked `fixed`: 1 red (the CONTROL that pins it as not judged), 41 green. </details> <details><summary>Package suite, gates, dogfood</summary> - `pnpm --filter @objectstack/service-analytics test`: 196 files, 4752 passed, 282 skipped. `typecheck` exit 0 (its `include` is `src`, tests included). - `node scripts/pm/dispatch-gates.mjs --commands`: 59 derived families, all run. `--ran` reconciliation: "59 derived, 58 run, 1 NOT-MEASURED, 0 UNRUN". 58 exited 0. NOT MEASURED: `check:dual-build-cjs-loads`, exit 3 (prerequisite: a whole-tree build; this run built only the dogfood closure and `@objectstack/service-analytics`). CI runs it. - `pnpm lint`: exit 0. - Full dogfood battery (the `(narrowing)` arm), `pnpm --filter @objectstack/dogfood test` under the verify lock at `c053f4ca`: VERDICT command-exit 0, Test Files 357 passed and 3 skipped (360), Tests 2875 passed and 20 skipped (2895), no failures. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cyL63KrU8pu6fycLHW)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cyL63KrU8pu6fycLHW Co-authored-by: Claude <noreply@anthropic.com>
1 parent 007ec1d commit 6dc06b2

5 files changed

Lines changed: 172 additions & 54 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 measure `currency` that contradicts its field's `fixed` code, and a derived measure across two different `fixed` codes (ADR-0144 open item 12)
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
**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.
11+
12+
- 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.
13+
- 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.
14+
15+
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.
16+
17+
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.
18+
19+
<!-- 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 -->

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,12 @@ 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 every field-side answer,
102-
// the field's FIXED currency included.
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).
103105
{ name: 'm_explicit_dynamic', aggregate: 'sum', field: 'amt_dynamic', currency: 'JPY' },
104-
{ name: 'm_explicit_fixed', aggregate: 'sum', field: 'amt_fixed', currency: 'JPY' },
106+
{ name: 'm_explicit_fixed', aggregate: 'sum', field: 'amt_fixed', currency: 'EUR' },
105107
{ name: 'm_units', aggregate: 'sum', field: 'units' },
106108
],
107109
});
@@ -220,11 +222,11 @@ describe.each<Shape>(['raw', 'parsed'])('[#20091] result-column currency honours
220222
expect(preview.m_bare).toBe('USD');
221223
});
222224

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

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

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

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

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);
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);
158161
const r = await svc.queryDataset(moneyDataset({ currency: 'JPY' }), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'GBP' } as ExecutionContext) as any;
159162
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('JPY');
160163
});

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

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@
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`), 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).
22+
* - The two compile-time guards (`measure-currency-coherence.ts`):
23+
* `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
25+
* combining a record-mode amount with another currency or two different
26+
* `fixed` codes. The `fixed` arms reach boot registration, `queryDataset`
27+
* and the draft preview alike (ruling 6486427200 on #8345).
2728
*
2829
* The SQLite cell runs everywhere; the strategies are the axis here, not the
2930
* 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', () => {
315316
);
316317
});
317318

318-
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)', () => {
319-
expect(compileRefusal([{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }])).toBeUndefined();
319+
it("a measure currency contradicting a fixed field's code is refused, naming the fix", () => {
320+
expectRefused([{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }], 'eur_as_jpy');
321+
expect(compileRefusal([{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }])?.message)
322+
.toContain("delete the measure's `currency`");
323+
});
324+
325+
it("CONTROL: a measure currency restating a fixed field's code is accepted", () => {
320326
expect(compileRefusal([{ name: 'eur', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' }])).toBeUndefined();
321327
});
322328

329+
it('CONTROL: a measure currency over a dynamic field is not judged (the tenant currency is unknown at compile)', () => {
330+
expect(compileRefusal([{ name: 'tenant_as_usd', aggregate: 'sum', field: 'amt_tenant', currency: 'USD' }])).toBeUndefined();
331+
});
332+
323333
const base = [
324334
{ name: 'usd', aggregate: 'sum', field: 'amt_usd' },
325335
{ name: 'eur', aggregate: 'sum', field: 'amt_eur' },
@@ -345,19 +355,75 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
345355
);
346356
});
347357

348-
it('CONTROL: a derived measure over two different fixed codes is not judged (ADR-0144 open item 12, its own card after R1)', () => {
349-
expect(compileRefusal([...base, { name: 'mixed', derived: { op: 'sum', of: ['usd', 'eur'] } }])).toBeUndefined();
358+
for (const op of ['sum', 'difference', 'ratio', 'product'] as const) {
359+
it(`a derived ${op} across two different fixed codes is refused, naming the fix`, () => {
360+
expectRefused([...base, { name: 'mixed', derived: { op, of: ['usd', 'eur'] } }], 'mixed');
361+
expect(compileRefusal([...base, { name: 'mixed', derived: { op, of: ['usd', 'eur'] } }])?.message)
362+
.toContain('derive it from measures in one currency, or drop the operand in the other');
363+
});
364+
}
365+
366+
it('a derived measure built on a fixed-code derived sum is judged at the next level', () => {
367+
expectRefused(
368+
[...base, { name: 'usd_twice', derived: { op: 'sum', of: ['usd', 'usd'] } }, { name: 'mixed', derived: { op: 'sum', of: ['usd_twice', 'eur'] } }],
369+
'mixed',
370+
);
371+
});
372+
373+
it('CONTROL: a fixed operand restated by its measure currency compares by that one code', () => {
374+
expect(
375+
compileRefusal([...base, { name: 'usd_restated', aggregate: 'sum', field: 'amt_usd', currency: 'USD' }, { name: 'both', derived: { op: 'sum', of: ['usd', 'usd_restated'] } }]),
376+
).toBeUndefined();
377+
});
378+
379+
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', () => {
380+
expect(
381+
compileRefusal([...base, { name: 'tenant_as_jpy', aggregate: 'sum', field: 'amt_tenant', currency: 'JPY' }, { name: 'mixed', derived: { op: 'sum', of: ['tenant_as_jpy', 'usd'] } }]),
382+
).toBeUndefined();
350383
});
351384

352385
it('accepted: an amount per invoice, an amount with itself, a fixed amount with the tenant currency', () => {
353386
expect(
354387
compileRefusal([
355388
...base,
356389
{ name: 'per_invoice', derived: { op: 'ratio', of: ['total', 'invoices'] } },
390+
{ name: 'usd_per_invoice', derived: { op: 'ratio', of: ['usd', 'invoices'] } },
391+
{ name: 'eur_per_invoice', derived: { op: 'ratio', of: ['eur', 'invoices'] } },
357392
{ name: 'usd_and_tenant', derived: { op: 'sum', of: ['usd', 'tenant'] } },
358393
{ name: 'total_share', derived: { op: 'ratio', of: ['total', 'total'] } },
359394
]),
360395
).toBeUndefined();
361396
});
397+
398+
// The request door and the draft preview compile through the same
399+
// `compileDataset` call as registration, so both fixed shapes refuse there too.
400+
const fixedShapes = [
401+
['a measure currency contradicting a fixed field', [{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }], 'eur_as_jpy'],
402+
['a derived measure across two different fixed codes', [...base, { name: 'mixed', derived: { op: 'difference', of: ['usd', 'eur'] } }], 'mixed'],
403+
] as const;
404+
for (const [label, measures, named] of fixedShapes) {
405+
for (const [door, options] of [['queryDataset', undefined], ['the draft preview', { previewDrafts: true }]] as const) {
406+
it(`${door} refuses ${label}`, async () => {
407+
const err = await refusalOf(
408+
services.objectql!.queryDataset(datasetWith([...measures]), { measures: [named] }, CTX, options as never),
409+
);
410+
expect(err.code, err.message).toBe('DATASET_INVALID');
411+
expect(err.status).toBe(400);
412+
expect(err.message).toContain(`"${named}"`);
413+
});
414+
}
415+
}
416+
417+
it(`CONTROL: queryDataset and the draft preview serve a measure currency restating a fixed field's code`, async () => {
418+
for (const options of [undefined, { previewDrafts: true }]) {
419+
const result = await services.objectql!.queryDataset(
420+
datasetWith([{ name: 'eur', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' }]),
421+
{ measures: ['eur'] },
422+
CTX,
423+
options as never,
424+
);
425+
expect(column(result, 'eur')?.currency).toBe('EUR');
426+
}
427+
});
362428
});
363429
});

0 commit comments

Comments
 (0)