Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/20091-analytics-currency-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/service-analytics': patch
---

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)

Clause-②: no

`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.

- **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:
- the field's currency for a `fixed` field;
- `ExecutionContext.currency` for a `dynamic` field, a config naming no mode, an empty config, or no config at all.
- **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.
- **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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20091] A dataset measure's result-column `currency` honours the source
* field's `currencyConfig.currencyMode`.
*
* ## The contract (spec, not this service)
*
* `CurrencyConfigSchema` (`packages/spec/src/data/field.zod.ts`) declares
* `currencyMode: 'dynamic' | 'fixed'`, defaulting to `'dynamic'`, and
* `defaultCurrency` defaulting to `'CNY'`. Its own precision refinement reads
* `defaultCurrency` as the field's one currency ONLY under `'fixed'` — "dynamic
* mode is out of reach BY DESIGN" — and the `FieldSchema` guidance for the
* mistaken `currency` key says a fixed currency is declared as
* `currencyConfig: { currencyMode: 'fixed', defaultCurrency: … }` and "A field
* without one uses the tenant default at runtime".
*
* So a `dynamic` field has no field-level currency. Its `defaultCurrency` is
* not the column's currency; the tenant default (`ExecutionContext.currency`)
* is. objectstack-ai/objectui#10461 made the renderer's `resolveFieldCurrency`
* read `defaultCurrency` only under `'fixed'`; the renderer reads an explicit
* column `currency` FIRST, so an analytics column that still carried a dynamic
* field's `defaultCurrency` overrode the renderer's rule on exactly the faces
* this service feeds.
*
* ## Why this suite boots the PLUGIN over a REAL engine
*
* The defect lived in the RELAY — `AnalyticsServicePlugin`'s `sourceFieldMeta`,
* which reads the registered object off the `'data'` engine — not in the
* service's chain. Every pre-existing currency case in this package stubs
* `sourceFieldMeta` directly, so none of them could see what the relay does
* with `currencyMode`. Here the object is registered on a real `ObjectQL`
* engine over `SqliteWasmDriver`, the plugin reads it the way production does,
* and the currency is read off the `queryDataset` RESULT.
*
* ## Two arrival shapes of "a config naming no mode"
*
* The registry does not parse (`SchemaRegistry.registerObject` stores what it
* is handed), so the relay sees whichever shape the door delivered:
*
* - RAW — a programmatic `registerObject`: `{ defaultCurrency: 'EUR' }` has
* NO `currencyMode` key at all, and `{}` has no `defaultCurrency` either.
* - PARSED — the declared-stack path (`ObjectSchema.parse`): the schema's
* defaults are materialised, so no-mode reads `currencyMode: 'dynamic'` and
* `{}` reads `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }` — the
* spec's placeholder `CNY` then leaked onto the column as if authored.
*
* Both must answer the same: no mode is `dynamic` by the schema default.
*
* ## Both `queryDataset` paths
*
* The one reader of the relay's currency is `enrichResultColumns`, which the
* live path and the ADR-0037 P3 draft-preview path both run. Each case below
* is asserted on both. `AnalyticsService.query` (the cube face) carries no
* column `currency` at all — the last block pins that, so "the cube face is
* unaffected" is a measurement rather than an assumption.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
import { ObjectQL } from '@objectstack/objectql';
import { ObjectSchema } from '@objectstack/spec/data';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { AnalyticsResult } from '@objectstack/spec/contracts';

import type { AnalyticsService } from '../analytics-service.js';
import { AnalyticsServicePlugin } from '../plugin.js';

const quiet = { debug() {}, info() {}, warn() {}, error() {}, trace() {}, fatal() {}, child() { return quiet; } };

const OBJECT = 'money_deal';

/** The fields as AUTHORED — one per case, plus a non-monetary control. */
const AUTHORED_FIELDS = {
stage: { type: 'text' },
amt_fixed: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } },
amt_dynamic: { type: 'currency', currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' } },
amt_nomode: { type: 'currency', currencyConfig: { defaultCurrency: 'EUR' } },
amt_emptycfg: { type: 'currency', currencyConfig: {} },
amt_bare: { type: 'currency' },
units: { type: 'number' },
} as const;

const ROWS = [
{ id: 'd1', stage: 'won', amt_fixed: 10, amt_dynamic: 10, amt_nomode: 10, amt_emptycfg: 10, amt_bare: 10, units: 1 },
{ id: 'd2', stage: 'lost', amt_fixed: 5, amt_dynamic: 5, amt_nomode: 5, amt_emptycfg: 5, amt_bare: 5, units: 2 },
];

const DATASET = DatasetSchema.parse({
name: 'money_by_stage',
label: 'Money by Stage',
object: OBJECT,
dimensions: [{ name: 'stage', field: 'stage', type: 'string' }],
measures: [
{ name: 'm_fixed', aggregate: 'sum', field: 'amt_fixed' },
{ name: 'm_dynamic', aggregate: 'sum', field: 'amt_dynamic' },
{ 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.
{ name: 'm_explicit_dynamic', aggregate: 'sum', field: 'amt_dynamic', currency: 'JPY' },
{ name: 'm_explicit_fixed', aggregate: 'sum', field: 'amt_fixed', currency: 'JPY' },
{ name: 'm_units', aggregate: 'sum', field: 'units' },
],
});

const SELECTION = {
dimensions: ['stage'],
measures: DATASET.measures!.map((m) => m.name),
};

/** A tenant whose default currency differs from every field's `defaultCurrency`. */
const CTX = { currency: 'USD' } as ExecutionContext;

type Shape = 'raw' | 'parsed';

function fieldsFor(shape: Shape): Record<string, unknown> {
if (shape === 'raw') return AUTHORED_FIELDS as unknown as Record<string, unknown>;
// The declared-stack door: the spec's own parse materialises the defaults.
return ObjectSchema.parse({ name: OBJECT, label: 'Money Deal', fields: AUTHORED_FIELDS }).fields as Record<string, unknown>;
}

/**
* A `'protocol'` service answering one PENDING seed draft for {@link OBJECT},
* which is what routes `queryDataset({ previewDrafts: true })` onto the
* draft-preview path through the plugin's own `draftRowsResolver`.
*/
const pendingSeedProtocol = {
getMetaItems: async () => [{ name: 'money_seed', object: OBJECT }],
getMetaItem: async () => ({ item: { records: ROWS } }),
};

async function bootPlugin(engine: ObjectQL): Promise<AnalyticsService> {
const registered: Record<string, unknown> = {};
const services: Record<string, unknown> = { data: engine, protocol: pendingSeedProtocol };
const ctx = {
getService: (name: string) => services[name] ?? registered[name],
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
logger: quiet,
};
await new AnalyticsServicePlugin({
// The engine path, so the live rows really come from the driver.
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
}).init(ctx as never);
return registered.analytics as AnalyticsService;
}

const currencyByColumn = (result: AnalyticsResult): Record<string, string | undefined> =>
Object.fromEntries(
(result.fields ?? [])
.filter((f) => f.name.startsWith('m_'))
.map((f) => [f.name, (f as { currency?: string }).currency]),
);

describe.each<Shape>(['raw', 'parsed'])('[#20091] result-column currency honours currencyMode — %s registration', (shape) => {
let driver: SqliteWasmDriver;
let service: AnalyticsService;
let live: Record<string, string | undefined>;
let preview: Record<string, string | undefined>;
let liveRows: AnalyticsResult['rows'];

beforeAll(async () => {
const fields = fieldsFor(shape);
driver = new SqliteWasmDriver({ filename: ':memory:' });
(driver as unknown as { logger: unknown }).logger = quiet;
await driver.initObjects([{ name: OBJECT, fields } as never]);
for (const row of ROWS) await driver.create(OBJECT, { ...row });
const engine = new ObjectQL({ logger: quiet } as never);
engine.registerDriver(driver as never, true);
await engine.init();
engine.registerObject({ name: OBJECT, label: 'Money Deal', fields } as never);
service = await bootPlugin(engine);

const liveResult = await service.queryDataset(DATASET, SELECTION, CTX);
const previewResult = await service.queryDataset(DATASET, SELECTION, CTX, { previewDrafts: true });
live = currencyByColumn(liveResult);
preview = currencyByColumn(previewResult);
liveRows = liveResult.rows;
});
afterAll(async () => {
await driver?.disconnect?.();
});

it('CONTROL: the registered object is what this shape says it is', () => {
// Without this the two describe blocks could be measuring one shape twice.
const nomode = (fieldsFor(shape).amt_nomode as { currencyConfig?: { currencyMode?: string } }).currencyConfig;
expect(nomode?.currencyMode).toBe(shape === 'raw' ? undefined : 'dynamic');
});

it('CONTROL: the live path really ran over the driver rows', () => {
const won = (liveRows as Array<Record<string, unknown>>).find((r) => r.stage === 'won');
expect(won?.m_fixed).toBe(10);
});

it("currencyMode 'fixed' — the column carries the field's currency", () => {
expect(live.m_fixed).toBe('EUR');
expect(preview.m_fixed).toBe('EUR');
});

it("currencyMode 'dynamic' — the column carries the tenant default, never the field's defaultCurrency", () => {
expect(live.m_dynamic).toBe('USD');
expect(preview.m_dynamic).toBe('USD');
});

it('a currencyConfig naming no mode is dynamic — the tenant default', () => {
expect(live.m_nomode).toBe('USD');
expect(preview.m_nomode).toBe('USD');
});

it("an empty currencyConfig is dynamic — the spec's defaulted CNY never reaches the column", () => {
expect(live.m_emptycfg).toBe('USD');
expect(preview.m_emptycfg).toBe('USD');
});

it('a currency field with no currencyConfig — the tenant default', () => {
expect(live.m_bare).toBe('USD');
expect(preview.m_bare).toBe('USD');
});

it('an explicit measure currency wins, over a dynamic field and over a fixed one', () => {
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');
});

it('CONTROL: a non-monetary measure carries no currency', () => {
expect(live.m_units).toBeUndefined();
expect(preview.m_units).toBeUndefined();
});
});

describe('[#20091] the cube face carries no column currency to disagree about', () => {
it('AnalyticsService.query over the same registered object mints no `currency` on any column', async () => {
const driver = new SqliteWasmDriver({ filename: ':memory:' });
(driver as unknown as { logger: unknown }).logger = quiet;
await driver.initObjects([{ name: OBJECT, fields: AUTHORED_FIELDS } as never]);
for (const row of ROWS) await driver.create(OBJECT, { ...row });
const engine = new ObjectQL({ logger: quiet } as never);
engine.registerDriver(driver as never, true);
await engine.init();
engine.registerObject({ name: OBJECT, label: 'Money Deal', fields: AUTHORED_FIELDS } as never);
const service = await bootPlugin(engine);
// Registering the dataset compiles its cube under the dataset's name.
service.registerDataset(DATASET);
try {
const result = await service.query(
{ cube: DATASET.name, dimensions: ['stage'], measures: ['m_fixed', 'm_dynamic'] } as never,
CTX,
);
// Positive control: the query answered, so "no currency" is about the
// columns and not about an empty response.
expect(result.fields?.map((f) => f.name)).toEqual(expect.arrayContaining(['m_fixed', 'm_dynamic']));
for (const f of result.fields ?? []) expect((f as { currency?: string }).currency).toBeUndefined();
} finally {
await driver.disconnect?.();
}
});
});
19 changes: 13 additions & 6 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,9 +683,15 @@ export interface AnalyticsServiceConfig {
* live on the field reach the result columns. `undefined` for an unknown
* field. Feeds three chains:
*
* - ADR-0053 currency: a monetary measure that omits an explicit `currency`
* falls back to the field's declared currency, then the tenant default
* - Currency: a monetary measure that omits an explicit `currency` falls
* back to the field's FIXED currency, then the tenant default
* (`ctx.currency`). Non-`currency` fields never get a code.
* [#20091] `defaultCurrency` here means the field's fixed currency: answer
* `currencyConfig.defaultCurrency` ONLY when `currencyConfig.currencyMode`
* is `'fixed'`, and `undefined` otherwise. A `dynamic` field — and a config
* naming no mode, `dynamic` by the spec's default — has no field-level
* currency; the tenant default applies. `AnalyticsServicePlugin`'s relay
* is the reference answer.
* - Percent scale (objectui#3136): a measure over a `percent` field inherits
* that field's storage scale via `percentScaleOf`, so a renderer scales by
* declared metadata instead of guessing from the value.
Expand Down Expand Up @@ -1789,10 +1795,11 @@ export class AnalyticsService implements IAnalyticsService {
// and break the moment the default is spelled in another language.
if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
if (f.format == null && m.format) f.format = m.format;
// ADR-0053 currency chain. A MONETARY measure resolves its display
// currency from: explicit measure `currency` → source-field
// `currencyConfig.defaultCurrency` → tenant default (`ctx.currency`). A
// measure is monetary if it declares a currency OR aggregates a
// Currency chain. A MONETARY measure resolves its display currency
// from: explicit measure `currency` → the source field's FIXED
// currency (`sourceFieldMeta().defaultCurrency`, relayed only under
// `currencyMode: 'fixed'` — #20091) → tenant default (`ctx.currency`).
// A measure is monetary if it declares a currency OR aggregates a
// `currency`-type field; non-monetary measures (count, avg of a plain
// number) never receive a currency code.
const fc = f as { currency?: string };
Expand Down
29 changes: 24 additions & 5 deletions packages/services/service-analytics/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1067,8 +1067,25 @@ export class AnalyticsServicePlugin implements Plugin {
// of the two moves.
debugSql: this.options.debugSql,
// Source-field metadata behind the display chains on result columns:
// ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
// (`max`, which is what marks whole-percent storage — objectui#3136).
// the field's FIXED currency and percent scale (`max`, which is what
// marks whole-percent storage — objectui#3136).
//
// [#20091] `defaultCurrency` is relayed ONLY under
// `currencyConfig.currencyMode === 'fixed'`. That is the spec's own
// reading of the pair (`CurrencyConfigSchema`, `packages/spec/src/data/
// field.zod.ts`): only `fixed` pins one currency to the field, and a
// field without one "uses the tenant default at runtime". `dynamic` —
// and a config naming no mode, which IS `dynamic` by the schema default —
// has no field currency, so its `defaultCurrency` (possibly the schema's
// placeholder `CNY`, materialised by a parse the author never saw) is
// not relayed and the result column falls through to `ctx.currency`.
// Decided HERE, at the one producer, so every reader of
// `sourceFieldMeta().defaultCurrency` inherits one rule — the same one
// objectui's `resolveFieldCurrency` applies on the field faces
// (objectstack-ai/objectui#10461). Mode is compared rather than
// defaulted: the registry stores what it was handed, so a raw
// registration arrives with no `currencyMode` key at all, and a parsed
// one with `'dynamic'` — both are "not fixed".
//
// [#17560] A formula field's declared `returnType` was relayed here too
// (#16236) for one reader — `measureResultType`'s `formula` branch. That
Expand All @@ -1078,11 +1095,13 @@ export class AnalyticsServicePlugin implements Plugin {
// on regardless would relay metadata into a seam nothing reads.
sourceFieldMeta: (object: string, field: string) => {
const f = dataEngine()?.getObject?.(object)?.fields?.[field] as
| { type?: string; max?: number; currencyConfig?: { defaultCurrency?: string } }
| { type?: string; max?: number; currencyConfig?: { currencyMode?: string; defaultCurrency?: string } }
| undefined;
return f
? { type: f.type, max: f.max, defaultCurrency: f.currencyConfig?.defaultCurrency }
if (!f) return undefined;
const fixedCurrency = f.currencyConfig?.currencyMode === 'fixed'
? f.currencyConfig.defaultCurrency
: undefined;
return { type: f.type, max: f.max, defaultCurrency: fixedCurrency };
},
// #5033 — the datasource an object is bound to, used ONLY to name the
// actual cause when a dataset's SQL references a table that is not on the
Expand Down
Loading