From 4cd46eb6f91d507587c05c2bafbc746a65e43a7b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:52:24 +0800 Subject: [PATCH 1/3] feat(analytics): propagate a measure's declared currency to the result field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dataset measure could declare a `format` (carried onto the analytics result field so the client renders "$616,000"), but there was NO way to declare a currency CODE. The analytics result enrichment set `label` + `format` on each measure field but never `currency` — so the client's currency-aware formatting (Intl symbol from a declared currency, ADR-0021; objectui DatasetWidget/report renderer) could never fire against real data: every amount fell back to a plain number or a "$" literal baked into `format`, regardless of the actual currency. This adds the missing link, symmetric with `format`: - spec: `DatasetMeasure.currency` (ISO 4217, optional) — declared on the semantic layer when the aggregated field is a fixed-currency amount. - service-analytics: carry `measure.currency` onto the result field alongside `label`/`format`, so the renderer gets a real currency code. - example (app-crm): `opportunity_metrics` total/avg amount now declare `currency: 'USD'` (and drop the legacy `$` from `format`) so the pipeline reports render a locale-correct symbol via Intl. Tests: service-analytics 138 passed (+1: a measure's declared currency rides onto its result field). Spec builds; the new field is additive + optional. Co-Authored-By: Claude Opus 4.8 --- .../src/datasets/opportunity.dataset.ts | 4 ++-- .../src/__tests__/query-dataset.test.ts | 23 +++++++++++++++++++ .../src/analytics-service.ts | 5 ++++ packages/spec/src/ui/dataset.zod.ts | 7 ++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/examples/app-crm/src/datasets/opportunity.dataset.ts b/examples/app-crm/src/datasets/opportunity.dataset.ts index c7756926d9..993d0422ed 100644 --- a/examples/app-crm/src/datasets/opportunity.dataset.ts +++ b/examples/app-crm/src/datasets/opportunity.dataset.ts @@ -25,7 +25,7 @@ export const OpportunityDataset = defineDataset({ measures: [ { name: 'opp_count', label: 'Opportunities', aggregate: 'count' }, - { name: 'total_amount', label: 'Total Amount', aggregate: 'sum', field: 'amount', format: '$0,0' }, - { name: 'avg_amount', label: 'Avg Deal Size', aggregate: 'avg', field: 'amount', format: '$0,0' }, + { name: 'total_amount', label: 'Total Amount', aggregate: 'sum', field: 'amount', format: '0,0', currency: 'USD' }, + { name: 'avg_amount', label: 'Avg Deal Size', aggregate: 'avg', field: 'amount', format: '0,0', currency: 'USD' }, ], }); 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 99a8d24c90..2a8c9ad399 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts @@ -99,6 +99,29 @@ describe('AnalyticsService.queryDataset', () => { expect(result.rows[0]).toEqual({ region: 'NA', revenue: 100 }); }); + it('enriches a measure column with its declared currency (ISO 4217)', async () => { + const priced = DatasetSchema.parse({ + name: 'sales_priced', label: 'Sales', object: 'opportunity', include: [], + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', label: 'Revenue', format: '0,0', currency: 'USD', certified: true }], + }); + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => [{ stage: 'Won', revenue: 1000 }], + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), + }); + const result = await svc.queryDataset( + priced, + { dimensions: ['stage'], measures: ['revenue'] }, + { tenantId: 'org_A' } as ExecutionContext, + ) as any; + // The measure's declared currency rides onto the result field so the client + // renders a locale-correct symbol via Intl (not a "$" baked into `format`). + const revenueField = (result.fields ?? []).find((f: any) => f.name === 'revenue'); + expect(revenueField?.currency).toBe('USD'); + expect(revenueField?.format).toBe('0,0'); + }); + it('enriches dimension columns with their dataset display label', async () => { const labeled = DatasetSchema.parse({ name: 'sales2', label: 'Sales', object: 'opportunity', include: ['account'], diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index a5975209b4..c60f9d47d1 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -524,6 +524,11 @@ export class AnalyticsService implements IAnalyticsService { if (!m) continue; if (f.label == null && typeof m.label === 'string') f.label = m.label; if (f.format == null && m.format) f.format = m.format; + // Carry the measure's declared currency so the renderer can render a + // locale-correct symbol via Intl (never a "$" baked into `format`). + const fc = f as { currency?: string }; + const mc = m as { currency?: string }; + if (fc.currency == null && mc.currency) fc.currency = mc.currency; } } diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index 8de31ed226..9a9ea54a92 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -72,6 +72,13 @@ export const DatasetMeasureSchema = lazySchema(() => z.object({ filter: FilterConditionSchema.optional(), /** Display format, e.g. "$0,0.00", "0.0%". */ format: z.string().optional(), + /** + * Display currency (ISO 4217, e.g. "USD", "CNY"). Carried onto the result + * field so presentations render a locale-correct symbol via `Intl` rather + * than a "$" baked into `format`. Declare it on the measure (the semantic + * layer) when the aggregated field is a fixed-currency amount. + */ + currency: z.string().length(3).optional().describe('Display currency code (ISO 4217)'), /** Governance: a human-blessed metric — the review checkpoint. */ certified: z.boolean().default(false).describe('Blessed metric (governance checkpoint)'), /** From a3562c42505b81080e79f1f61e80ee149da18d57 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:53:42 +0800 Subject: [PATCH 2/3] chore: add changeset for dataset-measure currency --- .changeset/dataset-measure-currency.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/dataset-measure-currency.md diff --git a/.changeset/dataset-measure-currency.md b/.changeset/dataset-measure-currency.md new file mode 100644 index 0000000000..06d9d7516d --- /dev/null +++ b/.changeset/dataset-measure-currency.md @@ -0,0 +1,12 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-analytics": minor +--- + +Propagate a dataset measure's declared currency to the analytics result field. + +Adds an optional `DatasetMeasure.currency` (ISO 4217) on the semantic layer and +carries it onto each measure result field alongside `label`/`format`, so a +currency-aware client (Intl symbol) can render `¥1,234` / `$616,000` from a real +currency code instead of a plain number or a `$` baked into `format`. Additive +and optional — existing datasets are unaffected. From 0f736ef41b07b37c15dac2d86394ebced8e7b1f1 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:03:36 +0800 Subject: [PATCH 3/3] chore(spec): classify dataset measures.currency in the liveness gate --- packages/spec/liveness/dataset.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/spec/liveness/dataset.json b/packages/spec/liveness/dataset.json index 5070f362de..e59efb8fc3 100644 --- a/packages/spec/liveness/dataset.json +++ b/packages/spec/liveness/dataset.json @@ -93,6 +93,11 @@ "evidence": "packages/services/service-analytics/src/analytics-service.ts:480", "note": "compiled into Cube metric + enriched onto result fields for the renderer." }, + "currency": { + "status": "live", + "evidence": "packages/services/service-analytics/src/analytics-service.ts:531", + "note": "measure-declared currency (ISO 4217) enriched onto result fields alongside label/format, so the renderer formats the amount with a locale-correct Intl symbol rather than a '$' baked into format." + }, "certified": { "status": "dead", "evidence": "no runtime consumer — analytics execution never reads it; not compiled into the Cube",