Skip to content

Commit 6780e34

Browse files
fix(service-analytics): a measure column takes the source field's currency only under currencyMode 'fixed' (#20091) (#20119)
Fixes #20091 Clause-②: no ## What was wrong `AnalyticsServicePlugin` passes each source field's metadata to the service through `sourceFieldMeta`. That hook passed on `currencyConfig.defaultCurrency` without reading `currencyConfig.currencyMode`. `queryDataset` resolves a monetary measure column's `currency` in this order: the measure's own `currency`, then that relayed value, then `ExecutionContext.currency`. So a `dynamic` field's `defaultCurrency` showed on the dataset result where the tenant currency belonged. The spec says otherwise: - `CurrencyConfigSchema` defaults `currencyMode` to `'dynamic'`. Its own precision refinement treats `defaultCurrency` as the field's one currency only under `'fixed'`. - The `FieldSchema` guidance says a fixed currency is `currencyConfig: { currencyMode: 'fixed', defaultCurrency: … }`, and "A field without one uses the tenant default at runtime". objectstack-ai/objectui#10461 changed the renderer's `resolveFieldCurrency` to read `defaultCurrency` only under `'fixed'`. The renderer reads an explicit column `currency` first, though, so the analytics faces kept overriding that rule. ## Measured before the fix (commit `9a98c59b44`: base `fa00ebf447` plus the new test file only), and after `AnalyticsServicePlugin` booted over a real `ObjectQL` engine on `SqliteWasmDriver`, with the object registered on the engine. `ExecutionContext.currency = 'USD'`, and every authored `defaultCurrency` is `EUR`. Each cell is the result column's `currency` on the live path / the draft-preview path of `queryDataset`: | case | raw registration, before | parsed registration (`ObjectSchema.parse`), before | after the fix (both registrations) | |:---|:---|:---|:---| | `{ currencyMode: 'fixed', defaultCurrency: 'EUR' }` | EUR / EUR | EUR / EUR | EUR / EUR | | `{ currencyMode: 'dynamic', defaultCurrency: 'EUR' }` | **EUR / EUR** | **EUR / EUR** | USD / USD | | `{ defaultCurrency: 'EUR' }` (no mode) | **EUR / EUR** | **EUR / EUR** | USD / USD | | `{}` (parses to `dynamic` + the schema's `CNY`) | USD / USD | **CNY / CNY** | USD / USD | | currency field, no `currencyConfig` | USD / USD | USD / USD | USD / USD | | measure `currency: 'JPY'` over the dynamic field | JPY / JPY | JPY / JPY | JPY / JPY | | measure `currency: 'JPY'` over the fixed field | JPY / JPY | JPY / JPY | JPY / JPY | | non-monetary control (`number` field) | none / none | none / none | none / none | Bold cells break the spec rule. The "before" columns are also in the message of commit `9a98c59b44`. The "after" column was measured on head `131de3a37a` with the same probe. **What "no mode" looks like at the relay.** `SchemaRegistry.registerObject` stores what it is handed and does not parse. A raw `registerObject` call therefore arrives with no `currencyMode` key at all. The declared-stack path parses first, which fills in `currencyMode: 'dynamic'`, and fills in `defaultCurrency: 'CNY'` when the author wrote none. Measured with `ObjectSchema.parse`: `{ defaultCurrency: 'EUR' }` parses to `{"precision":2,"currencyMode":"dynamic","defaultCurrency":"EUR"}`, and `{}` parses to `{"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}`. The fix compares `currencyMode === 'fixed'`, so both shapes fall to the tenant default. The relay supplies no default of its own. **Every reader of `sourceFieldMeta().defaultCurrency` in the package: one.** It is `enrichResultColumns` (`analytics-service.ts`, the currency chain). The live and draft-preview paths of `queryDataset` both reach it, and the same loop covers `__compare` columns. A grep over `src/` for `defaultCurrency` and for `currenc` finds nothing else: - `sourceFieldMeta`'s other readers take only `.type` (the compile gate at `registerDataset`, the time-dimension type, `measureResultType`) or `max` (`percentScaleOf`). - Dimension columns get only a `label`. - Drill metadata and dimension value labels carry no currency. - The package has no export face. - The cube face (`AnalyticsService.query` and its SQL echo) never writes a column `currency`. The suite pins that as measured. ## The fix - `plugin.ts`: the relay answers `defaultCurrency` only when `currencyConfig.currencyMode === 'fixed'`, and `undefined` otherwise. The rule is decided at the one producer, so the one reader inherits it. An explicit measure `currency` still wins. - No reader needs the raw `defaultCurrency`, so no second key was added. The public `AnalyticsServiceConfig.sourceFieldMeta` return type is unchanged. Its TSDoc now says what `defaultCurrency` means there (the field's FIXED currency) and what a host writing its own `sourceFieldMeta` must answer. - No `packages/spec` change and no objectui change. ## Tests New `packages/services/service-analytics/src/__tests__/currency-mode-relay.test.ts`, 19 cases. Each case runs end to end through the plugin's relay, the real engine and driver, and the `queryDataset` result, under both registration shapes. Each asserts the live and the preview path. The cases: - fixed, dynamic, no mode, empty config, no config; - an explicit measure currency over a dynamic field and over a fixed one; - a non-monetary control; - two fixture controls: the shape really differs, and the live rows really came from the driver; - the cube-face pin. Before the fix: 5 failed, 14 passed. After: 19 passed. The failures before the fix were the 5 bold cells above: ```text AssertionError: expected 'EUR' to be 'USD' (dynamic, raw + parsed; no mode, raw + parsed) AssertionError: expected 'CNY' to be 'USD' (empty config, parsed) Tests 5 failed | 14 passed (19) ``` **Ablation.** Every negative pin was ablated through `node scripts/ablation-replace.mjs` in WRAP mode, after the fix was committed. Each mutation was proven on disk: the anchor count went 1 to 0 and the blob changed. Each restore was proven too: the blob equals `HEAD` and `git diff HEAD` is empty. | leg | mutation | red | |:---|:---|:---| | A | `plugin.ts`: the pre-fix relay, which passes `defaultCurrency` whenever present | 5 failed / 14 passed: dynamic ×2, no mode ×2, empty config (parsed) | | B | `plugin.ts`: a relay that fills in the schema's `CNY` itself and ignores mode | 8 failed / 11 passed: dynamic ×2, no mode ×2, empty config ×2, no config ×2 | | C | `analytics-service.ts`: the cube face writes a `currency` on its columns | 13 failed / 6 passed, including the cube-face pin | | D | `analytics-service.ts`: every measure treated as monetary | 2 failed / 17 passed: the non-monetary control ×2 | | E | test fixture: the raw shape is parsed too | 1 failed / 18 passed: the shape control | Leg C's first attempt did not run. Its replacement text still contained the anchor, so `ablation-replace` refused the leg before running anything ("the anchor count moved 1 -> 1"), and the file was restored. It was re-run with a replacement that consumes the anchor, and that run is the one in the table. Package suite on the merged head `131de3a37a`: `pnpm --filter @objectstack/service-analytics test`, 125 files / 2905 tests passed. `pnpm --filter @objectstack/service-analytics run typecheck` exits 0. `tsc --listFiles` includes the new test file (1 hit; the control `plugin.ts` also has 1). ## Changeset `.changeset/20091-analytics-currency-mode.md`: `@objectstack/service-analytics`, **patch**, with a `Clause-②: no` line. It is a fix to a released package that adds or removes no authorable key, export or type member. `check-changeset-no-major --base origin/main` exits 0, and so does `check-adr-0087-registration --base origin/main`: the changeset is not declared breaking, so no ADR-0087 marker is owed. ## Acceptance notes - **The code labels this chain "ADR-0053", but ADR-0053 says nothing about currency.** ADR-0053 is date/datetime semantics: `grep -ci currenc` on it gives 0, and the control `datetime` gives 73. This PR corrects the label on the lines it rewrites (`plugin.ts` relay comment, `analytics-service.ts` TSDoc and chain comment). It does not touch the same label in `preview-column-enrichment.test.ts` (the header table and two test names). No carrier: noted, not filed. - **`{ currencyMode: 'fixed' }` with no code differs by registration door.** Not measured, a source read. A raw registration relays `undefined`, so the column gets the tenant default. A parsed one relays the spec's defaulted `CNY`, which the spec's own refinement treats as the field's currency in fixed mode. The relay follows the registered shape on purpose. Supplying the spec default here would be a second copy of it. - **A host that writes its own `sourceFieldMeta`** must apply the same rule. A type cannot express it, so the TSDoc and the changeset state it. ## Local verification (head `131de3a37a`, after the `origin/main` merge) - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 61 commands from this diff, and all 61 exit 0. `--ran` (each command recorded with its exit code) answers: "61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN". - The derivation added six families to the dispatch-time list: `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. - `check:dual-build-cjs-loads` and `check:type-check-debt` first answered `PREREQUISITE NOT MET` (exit 3), which is not a measurement. Both exit 0 after `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72/72 tasks). - `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs` exits 0: 5 citations, 3 resolve, 2 are cross-repo. - **Lint, narrowed to the touched files** (`pnpm lint` over the whole repo is CI's job): - `eslint --no-inline-config --format json` on the 3 touched TypeScript files exits 0. The JSON reports 3 files, with 0 errors and 0 warnings. - `eslint --print-config` puts all 3 files in the linted population: 6 rules each on the two sources, and 5 on the test file. - Linting only these files misses nothing. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), and its local rules are scope-based and per-file. A diff to these files therefore cannot change a verdict on any untouched file. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d73ff6 commit 6780e34

4 files changed

Lines changed: 314 additions & 11 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): a dataset measure column takes the source field's currency only when that field's `currencyConfig.currencyMode` is `'fixed'`. Otherwise it takes the tenant default (#20091)
6+
7+
Clause-②: no
8+
9+
`AnalyticsServicePlugin` passes each source field's metadata to the service through `sourceFieldMeta`. That hook passed on `currencyConfig.defaultCurrency` whatever `currencyMode` said, and `queryDataset` put it on the result column as `currency`. The column is resolved in this order: the measure's own `currency`, then that value, then `ExecutionContext.currency`. So a `dynamic` field's `defaultCurrency` showed on analytics, chart and dataset faces, where the tenant currency belonged. That covers a `currencyConfig` naming no mode too, which is `dynamic` by the schema default. Parsed through the spec, a `currencyConfig: {}` also carries the schema's placeholder `CNY`, and that reached the column as if an author had written it.
10+
11+
- **What changes**: the relay now passes `defaultCurrency` on only under `currencyMode: 'fixed'`. This is the rule `CurrencyConfigSchema` declares, and objectui's field faces already follow it: only `fixed` gives a field one currency, and a field without one uses the tenant default at runtime. On both the live and the draft-preview path of `queryDataset`, the column now carries:
12+
- the field's currency for a `fixed` field;
13+
- `ExecutionContext.currency` for a `dynamic` field, a config naming no mode, an empty config, or no config at all.
14+
- **What does not change**: a measure's explicit `currency` still wins, over a fixed field as well. A non-monetary measure still gets no code. `AnalyticsService.query` (the cube face) still carries no column currency. The `AnalyticsServiceConfig.sourceFieldMeta` type is unchanged.
15+
- **Hosts that write their own `sourceFieldMeta`**: its `defaultCurrency` means the field's fixed currency. Return `currencyConfig.defaultCurrency` only when `currencyConfig.currencyMode === 'fixed'`, and return `undefined` otherwise. The TSDoc on `AnalyticsServiceConfig.sourceFieldMeta` states the rule.
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20091] A dataset measure's result-column `currency` honours the source
5+
* field's `currencyConfig.currencyMode`.
6+
*
7+
* ## The contract (spec, not this service)
8+
*
9+
* `CurrencyConfigSchema` (`packages/spec/src/data/field.zod.ts`) declares
10+
* `currencyMode: 'dynamic' | 'fixed'`, defaulting to `'dynamic'`, and
11+
* `defaultCurrency` defaulting to `'CNY'`. Its own precision refinement reads
12+
* `defaultCurrency` as the field's one currency ONLY under `'fixed'` — "dynamic
13+
* mode is out of reach BY DESIGN" — and the `FieldSchema` guidance for the
14+
* mistaken `currency` key says a fixed currency is declared as
15+
* `currencyConfig: { currencyMode: 'fixed', defaultCurrency: … }` and "A field
16+
* without one uses the tenant default at runtime".
17+
*
18+
* So a `dynamic` field has no field-level currency. Its `defaultCurrency` is
19+
* not the column's currency; the tenant default (`ExecutionContext.currency`)
20+
* is. objectstack-ai/objectui#10461 made the renderer's `resolveFieldCurrency`
21+
* read `defaultCurrency` only under `'fixed'`; the renderer reads an explicit
22+
* column `currency` FIRST, so an analytics column that still carried a dynamic
23+
* field's `defaultCurrency` overrode the renderer's rule on exactly the faces
24+
* this service feeds.
25+
*
26+
* ## Why this suite boots the PLUGIN over a REAL engine
27+
*
28+
* The defect lived in the RELAY — `AnalyticsServicePlugin`'s `sourceFieldMeta`,
29+
* which reads the registered object off the `'data'` engine — not in the
30+
* service's chain. Every pre-existing currency case in this package stubs
31+
* `sourceFieldMeta` directly, so none of them could see what the relay does
32+
* with `currencyMode`. Here the object is registered on a real `ObjectQL`
33+
* engine over `SqliteWasmDriver`, the plugin reads it the way production does,
34+
* and the currency is read off the `queryDataset` RESULT.
35+
*
36+
* ## Two arrival shapes of "a config naming no mode"
37+
*
38+
* The registry does not parse (`SchemaRegistry.registerObject` stores what it
39+
* is handed), so the relay sees whichever shape the door delivered:
40+
*
41+
* - RAW — a programmatic `registerObject`: `{ defaultCurrency: 'EUR' }` has
42+
* NO `currencyMode` key at all, and `{}` has no `defaultCurrency` either.
43+
* - PARSED — the declared-stack path (`ObjectSchema.parse`): the schema's
44+
* defaults are materialised, so no-mode reads `currencyMode: 'dynamic'` and
45+
* `{}` reads `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }` — the
46+
* spec's placeholder `CNY` then leaked onto the column as if authored.
47+
*
48+
* Both must answer the same: no mode is `dynamic` by the schema default.
49+
*
50+
* ## Both `queryDataset` paths
51+
*
52+
* The one reader of the relay's currency is `enrichResultColumns`, which the
53+
* live path and the ADR-0037 P3 draft-preview path both run. Each case below
54+
* is asserted on both. `AnalyticsService.query` (the cube face) carries no
55+
* column `currency` at all — the last block pins that, so "the cube face is
56+
* unaffected" is a measurement rather than an assumption.
57+
*/
58+
59+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
60+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
61+
import { ObjectQL } from '@objectstack/objectql';
62+
import { ObjectSchema } from '@objectstack/spec/data';
63+
import { DatasetSchema } from '@objectstack/spec/ui';
64+
import type { ExecutionContext } from '@objectstack/spec/kernel';
65+
import type { AnalyticsResult } from '@objectstack/spec/contracts';
66+
67+
import type { AnalyticsService } from '../analytics-service.js';
68+
import { AnalyticsServicePlugin } from '../plugin.js';
69+
70+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, trace() {}, fatal() {}, child() { return quiet; } };
71+
72+
const OBJECT = 'money_deal';
73+
74+
/** The fields as AUTHORED — one per case, plus a non-monetary control. */
75+
const AUTHORED_FIELDS = {
76+
stage: { type: 'text' },
77+
amt_fixed: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } },
78+
amt_dynamic: { type: 'currency', currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' } },
79+
amt_nomode: { type: 'currency', currencyConfig: { defaultCurrency: 'EUR' } },
80+
amt_emptycfg: { type: 'currency', currencyConfig: {} },
81+
amt_bare: { type: 'currency' },
82+
units: { type: 'number' },
83+
} as const;
84+
85+
const ROWS = [
86+
{ id: 'd1', stage: 'won', amt_fixed: 10, amt_dynamic: 10, amt_nomode: 10, amt_emptycfg: 10, amt_bare: 10, units: 1 },
87+
{ id: 'd2', stage: 'lost', amt_fixed: 5, amt_dynamic: 5, amt_nomode: 5, amt_emptycfg: 5, amt_bare: 5, units: 2 },
88+
];
89+
90+
const DATASET = DatasetSchema.parse({
91+
name: 'money_by_stage',
92+
label: 'Money by Stage',
93+
object: OBJECT,
94+
dimensions: [{ name: 'stage', field: 'stage', type: 'string' }],
95+
measures: [
96+
{ name: 'm_fixed', aggregate: 'sum', field: 'amt_fixed' },
97+
{ name: 'm_dynamic', aggregate: 'sum', field: 'amt_dynamic' },
98+
{ name: 'm_nomode', aggregate: 'sum', field: 'amt_nomode' },
99+
{ name: 'm_emptycfg', aggregate: 'sum', field: 'amt_emptycfg' },
100+
{ 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.
103+
{ name: 'm_explicit_dynamic', aggregate: 'sum', field: 'amt_dynamic', currency: 'JPY' },
104+
{ name: 'm_explicit_fixed', aggregate: 'sum', field: 'amt_fixed', currency: 'JPY' },
105+
{ name: 'm_units', aggregate: 'sum', field: 'units' },
106+
],
107+
});
108+
109+
const SELECTION = {
110+
dimensions: ['stage'],
111+
measures: DATASET.measures!.map((m) => m.name),
112+
};
113+
114+
/** A tenant whose default currency differs from every field's `defaultCurrency`. */
115+
const CTX = { currency: 'USD' } as ExecutionContext;
116+
117+
type Shape = 'raw' | 'parsed';
118+
119+
function fieldsFor(shape: Shape): Record<string, unknown> {
120+
if (shape === 'raw') return AUTHORED_FIELDS as unknown as Record<string, unknown>;
121+
// The declared-stack door: the spec's own parse materialises the defaults.
122+
return ObjectSchema.parse({ name: OBJECT, label: 'Money Deal', fields: AUTHORED_FIELDS }).fields as Record<string, unknown>;
123+
}
124+
125+
/**
126+
* A `'protocol'` service answering one PENDING seed draft for {@link OBJECT},
127+
* which is what routes `queryDataset({ previewDrafts: true })` onto the
128+
* draft-preview path through the plugin's own `draftRowsResolver`.
129+
*/
130+
const pendingSeedProtocol = {
131+
getMetaItems: async () => [{ name: 'money_seed', object: OBJECT }],
132+
getMetaItem: async () => ({ item: { records: ROWS } }),
133+
};
134+
135+
async function bootPlugin(engine: ObjectQL): Promise<AnalyticsService> {
136+
const registered: Record<string, unknown> = {};
137+
const services: Record<string, unknown> = { data: engine, protocol: pendingSeedProtocol };
138+
const ctx = {
139+
getService: (name: string) => services[name] ?? registered[name],
140+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
141+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
142+
logger: quiet,
143+
};
144+
await new AnalyticsServicePlugin({
145+
// The engine path, so the live rows really come from the driver.
146+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
147+
}).init(ctx as never);
148+
return registered.analytics as AnalyticsService;
149+
}
150+
151+
const currencyByColumn = (result: AnalyticsResult): Record<string, string | undefined> =>
152+
Object.fromEntries(
153+
(result.fields ?? [])
154+
.filter((f) => f.name.startsWith('m_'))
155+
.map((f) => [f.name, (f as { currency?: string }).currency]),
156+
);
157+
158+
describe.each<Shape>(['raw', 'parsed'])('[#20091] result-column currency honours currencyMode — %s registration', (shape) => {
159+
let driver: SqliteWasmDriver;
160+
let service: AnalyticsService;
161+
let live: Record<string, string | undefined>;
162+
let preview: Record<string, string | undefined>;
163+
let liveRows: AnalyticsResult['rows'];
164+
165+
beforeAll(async () => {
166+
const fields = fieldsFor(shape);
167+
driver = new SqliteWasmDriver({ filename: ':memory:' });
168+
(driver as unknown as { logger: unknown }).logger = quiet;
169+
await driver.initObjects([{ name: OBJECT, fields } as never]);
170+
for (const row of ROWS) await driver.create(OBJECT, { ...row });
171+
const engine = new ObjectQL({ logger: quiet } as never);
172+
engine.registerDriver(driver as never, true);
173+
await engine.init();
174+
engine.registerObject({ name: OBJECT, label: 'Money Deal', fields } as never);
175+
service = await bootPlugin(engine);
176+
177+
const liveResult = await service.queryDataset(DATASET, SELECTION, CTX);
178+
const previewResult = await service.queryDataset(DATASET, SELECTION, CTX, { previewDrafts: true });
179+
live = currencyByColumn(liveResult);
180+
preview = currencyByColumn(previewResult);
181+
liveRows = liveResult.rows;
182+
});
183+
afterAll(async () => {
184+
await driver?.disconnect?.();
185+
});
186+
187+
it('CONTROL: the registered object is what this shape says it is', () => {
188+
// Without this the two describe blocks could be measuring one shape twice.
189+
const nomode = (fieldsFor(shape).amt_nomode as { currencyConfig?: { currencyMode?: string } }).currencyConfig;
190+
expect(nomode?.currencyMode).toBe(shape === 'raw' ? undefined : 'dynamic');
191+
});
192+
193+
it('CONTROL: the live path really ran over the driver rows', () => {
194+
const won = (liveRows as Array<Record<string, unknown>>).find((r) => r.stage === 'won');
195+
expect(won?.m_fixed).toBe(10);
196+
});
197+
198+
it("currencyMode 'fixed' — the column carries the field's currency", () => {
199+
expect(live.m_fixed).toBe('EUR');
200+
expect(preview.m_fixed).toBe('EUR');
201+
});
202+
203+
it("currencyMode 'dynamic' — the column carries the tenant default, never the field's defaultCurrency", () => {
204+
expect(live.m_dynamic).toBe('USD');
205+
expect(preview.m_dynamic).toBe('USD');
206+
});
207+
208+
it('a currencyConfig naming no mode is dynamic — the tenant default', () => {
209+
expect(live.m_nomode).toBe('USD');
210+
expect(preview.m_nomode).toBe('USD');
211+
});
212+
213+
it("an empty currencyConfig is dynamic — the spec's defaulted CNY never reaches the column", () => {
214+
expect(live.m_emptycfg).toBe('USD');
215+
expect(preview.m_emptycfg).toBe('USD');
216+
});
217+
218+
it('a currency field with no currencyConfig — the tenant default', () => {
219+
expect(live.m_bare).toBe('USD');
220+
expect(preview.m_bare).toBe('USD');
221+
});
222+
223+
it('an explicit measure currency wins, over a dynamic field and over a fixed one', () => {
224+
expect(live.m_explicit_dynamic).toBe('JPY');
225+
expect(preview.m_explicit_dynamic).toBe('JPY');
226+
expect(live.m_explicit_fixed).toBe('JPY');
227+
expect(preview.m_explicit_fixed).toBe('JPY');
228+
});
229+
230+
it('CONTROL: a non-monetary measure carries no currency', () => {
231+
expect(live.m_units).toBeUndefined();
232+
expect(preview.m_units).toBeUndefined();
233+
});
234+
});
235+
236+
describe('[#20091] the cube face carries no column currency to disagree about', () => {
237+
it('AnalyticsService.query over the same registered object mints no `currency` on any column', async () => {
238+
const driver = new SqliteWasmDriver({ filename: ':memory:' });
239+
(driver as unknown as { logger: unknown }).logger = quiet;
240+
await driver.initObjects([{ name: OBJECT, fields: AUTHORED_FIELDS } as never]);
241+
for (const row of ROWS) await driver.create(OBJECT, { ...row });
242+
const engine = new ObjectQL({ logger: quiet } as never);
243+
engine.registerDriver(driver as never, true);
244+
await engine.init();
245+
engine.registerObject({ name: OBJECT, label: 'Money Deal', fields: AUTHORED_FIELDS } as never);
246+
const service = await bootPlugin(engine);
247+
// Registering the dataset compiles its cube under the dataset's name.
248+
service.registerDataset(DATASET);
249+
try {
250+
const result = await service.query(
251+
{ cube: DATASET.name, dimensions: ['stage'], measures: ['m_fixed', 'm_dynamic'] } as never,
252+
CTX,
253+
);
254+
// Positive control: the query answered, so "no currency" is about the
255+
// columns and not about an empty response.
256+
expect(result.fields?.map((f) => f.name)).toEqual(expect.arrayContaining(['m_fixed', 'm_dynamic']));
257+
for (const f of result.fields ?? []) expect((f as { currency?: string }).currency).toBeUndefined();
258+
} finally {
259+
await driver.disconnect?.();
260+
}
261+
});
262+
});

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -683,9 +683,15 @@ export interface AnalyticsServiceConfig {
683683
* live on the field reach the result columns. `undefined` for an unknown
684684
* field. Feeds three chains:
685685
*
686-
* - ADR-0053 currency: a monetary measure that omits an explicit `currency`
687-
* falls back to the field's declared currency, then the tenant default
686+
* - Currency: a monetary measure that omits an explicit `currency` falls
687+
* back to the field's FIXED currency, then the tenant default
688688
* (`ctx.currency`). Non-`currency` fields never get a code.
689+
* [#20091] `defaultCurrency` here means the field's fixed currency: answer
690+
* `currencyConfig.defaultCurrency` ONLY when `currencyConfig.currencyMode`
691+
* is `'fixed'`, and `undefined` otherwise. A `dynamic` field — and a config
692+
* naming no mode, `dynamic` by the spec's default — has no field-level
693+
* currency; the tenant default applies. `AnalyticsServicePlugin`'s relay
694+
* is the reference answer.
689695
* - Percent scale (objectui#3136): a measure over a `percent` field inherits
690696
* that field's storage scale via `percentScaleOf`, so a renderer scales by
691697
* declared metadata instead of guessing from the value.
@@ -1789,10 +1795,11 @@ export class AnalyticsService implements IAnalyticsService {
17891795
// and break the moment the default is spelled in another language.
17901796
if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
17911797
if (f.format == null && m.format) f.format = m.format;
1792-
// ADR-0053 currency chain. A MONETARY measure resolves its display
1793-
// currency from: explicit measure `currency` → source-field
1794-
// `currencyConfig.defaultCurrency` → tenant default (`ctx.currency`). A
1795-
// measure is monetary if it declares a currency OR aggregates a
1798+
// Currency chain. A MONETARY measure resolves its display currency
1799+
// from: explicit measure `currency` → the source field's FIXED
1800+
// currency (`sourceFieldMeta().defaultCurrency`, relayed only under
1801+
// `currencyMode: 'fixed'` — #20091) → tenant default (`ctx.currency`).
1802+
// A measure is monetary if it declares a currency OR aggregates a
17961803
// `currency`-type field; non-monetary measures (count, avg of a plain
17971804
// number) never receive a currency code.
17981805
const fc = f as { currency?: string };

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

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,8 +1067,25 @@ export class AnalyticsServicePlugin implements Plugin {
10671067
// of the two moves.
10681068
debugSql: this.options.debugSql,
10691069
// Source-field metadata behind the display chains on result columns:
1070-
// ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
1071-
// (`max`, which is what marks whole-percent storage — objectui#3136).
1070+
// the field's FIXED currency and percent scale (`max`, which is what
1071+
// marks whole-percent storage — objectui#3136).
1072+
//
1073+
// [#20091] `defaultCurrency` is relayed ONLY under
1074+
// `currencyConfig.currencyMode === 'fixed'`. That is the spec's own
1075+
// reading of the pair (`CurrencyConfigSchema`, `packages/spec/src/data/
1076+
// field.zod.ts`): only `fixed` pins one currency to the field, and a
1077+
// field without one "uses the tenant default at runtime". `dynamic` —
1078+
// and a config naming no mode, which IS `dynamic` by the schema default —
1079+
// has no field currency, so its `defaultCurrency` (possibly the schema's
1080+
// placeholder `CNY`, materialised by a parse the author never saw) is
1081+
// not relayed and the result column falls through to `ctx.currency`.
1082+
// Decided HERE, at the one producer, so every reader of
1083+
// `sourceFieldMeta().defaultCurrency` inherits one rule — the same one
1084+
// objectui's `resolveFieldCurrency` applies on the field faces
1085+
// (objectstack-ai/objectui#10461). Mode is compared rather than
1086+
// defaulted: the registry stores what it was handed, so a raw
1087+
// registration arrives with no `currencyMode` key at all, and a parsed
1088+
// one with `'dynamic'` — both are "not fixed".
10721089
//
10731090
// [#17560] A formula field's declared `returnType` was relayed here too
10741091
// (#16236) for one reader — `measureResultType`'s `formula` branch. That
@@ -1078,11 +1095,13 @@ export class AnalyticsServicePlugin implements Plugin {
10781095
// on regardless would relay metadata into a seam nothing reads.
10791096
sourceFieldMeta: (object: string, field: string) => {
10801097
const f = dataEngine()?.getObject?.(object)?.fields?.[field] as
1081-
| { type?: string; max?: number; currencyConfig?: { defaultCurrency?: string } }
1098+
| { type?: string; max?: number; currencyConfig?: { currencyMode?: string; defaultCurrency?: string } }
10821099
| undefined;
1083-
return f
1084-
? { type: f.type, max: f.max, defaultCurrency: f.currencyConfig?.defaultCurrency }
1100+
if (!f) return undefined;
1101+
const fixedCurrency = f.currencyConfig?.currencyMode === 'fixed'
1102+
? f.currencyConfig.defaultCurrency
10851103
: undefined;
1104+
return { type: f.type, max: f.max, defaultCurrency: fixedCurrency };
10861105
},
10871106
// #5033 — the datasource an object is bound to, used ONLY to name the
10881107
// actual cause when a dataset's SQL references a table that is not on the

0 commit comments

Comments
 (0)