Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
59ac9e8
feat(spec): currencyMode 'record' + currencyField — a record-mode amo…
claude Oct 28, 2026
2537aff
chore(spec): regenerate artifacts and declare the record-mode refinem…
claude Oct 28, 2026
1d9a533
chore(spec): dropped-refinements ledger header totals for the record-…
claude Oct 28, 2026
d1316dd
docs(skills): field-types — the record currency mode and its sibling-…
claude Oct 28, 2026
5232c8b
chore(platform-objects): metadata-form translation bundles for the re…
claude Oct 28, 2026
03062f3
feat(spec,service-analytics): record-mode amounts — per-row currencyF…
claude Oct 28, 2026
699ed05
test(service-analytics): record-currency analytics on both strategies…
claude Oct 28, 2026
9416158
Merge origin/main (#27218 landed) into the record-mode branch
claude Oct 28, 2026
80b98fb
test(service-analytics): a measure currency over a fixed field may on…
claude Oct 28, 2026
a9f5abf
chore(spec): regenerate artifacts after bringing main in
claude Oct 28, 2026
aff056f
feat(spec): the analytics result wire schema carries currencyField be…
claude Oct 28, 2026
1276434
chore(changeset): record-currency analytics — spec and service-analyt…
claude Oct 28, 2026
fea128c
chore(changeset): 27187 declares Clause-② yes (narrowing) / Metadata-…
claude Oct 28, 2026
da1ebc7
fix(i18n): resolveFieldCurrency accepts the record currency mode — th…
claude Oct 28, 2026
f174d4b
test(platform-objects): move the metadata-form catalog pins for the c…
claude Oct 28, 2026
bf7f2b7
fix(i18n): a record-mode field resolves no currency, never the tenant…
claude Oct 28, 2026
507ed8f
fix(service-analytics): the compile guards judge record-mode amounts …
claude Oct 29, 2026
e527c3b
Merge origin/main (#27233, #27239) into claude/project-thread-0wmk7j
claude Oct 29, 2026
9011464
chore(spec): regenerate artifacts after bringing main in
claude Oct 29, 2026
2aa89a8
Merge #27268 final head 90114641 into #27187
claude Oct 29, 2026
3eaecb2
docs(service-analytics): cite ADR-0144 as landed — open item 12 and i…
claude Oct 29, 2026
d24c889
docs(api): name fields[].currencyField and the record-currency aggreg…
claude Oct 29, 2026
f3d286c
fix(spec): record currency mode refuses an empty pointer and an inert…
claude Oct 29, 2026
b6f152b
docs(spec): three-mode currency help texts, translated; the skill's c…
claude Oct 29, 2026
ae8e3d4
Merge #27268 head b6f152bd into #27187
claude Oct 29, 2026
0d2291d
fix(service-analytics): judge the record-currency door on the inferre…
claude Oct 29, 2026
48cdeb4
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 29, 2026
f79f10d
merge origin/main (os-regen artifacts taken from main; regeneration f…
claude Oct 29, 2026
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
23 changes: 23 additions & 0 deletions .changeset/27187-record-currency-analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/spec': minor
'@objectstack/service-analytics': minor
---

feat(spec,service-analytics): analytics over a record-currency amount — grouped answers name a per-row currency, an ungrouped amount aggregate is refused, and two compile-time guards on record-mode measures (ADR-0144 D2, D6)

Clause-②: yes (widening)
Metadata-protocol: added

A measure column over a `currencyMode: 'record'` amount now carries `fields[].currencyField` on a `queryDataset` answer: the name of the dimension column that groups by the amount's code field. Each row's currency is that row's value of it, and the column carries no `currency`. Before, a breakdown by currency labelled every group with one code.

- `AnalyticsResult.fields[].currencyField` (`@objectstack/spec/contracts`) and the same key on `AnalyticsResultResponseSchema` (`@objectstack/spec/api`).
- The cube door refuses a `sum`, `avg`, `min` or `max` over a record-mode amount whose query does not group by the code field, with `INVALID_FIELD` / 400 naming the measure, the field and its object. It judges every path: a dataset or registered cube, an ad-hoc `query` over the object (`amount_sum`), and a draft preview. A `totals` grouping that drops the code, the grand total included, is refused the same way. `count` reads no amount and is served, and its column carries neither currency key.

Two compile-time refusals, `DATASET_INVALID` / 400, on the record mode alone, so nothing that compiled before is refused:

- a measure `currency` over a record-mode amount, whose currency is per row, including on a derived measure that resolves to one;
- a derived measure that combines a record-mode amount with an operand in another currency (another code field, a `fixed` code, an explicit code or the tenant's), for every op. A count operand is not compared, so an amount-per-invoice ratio still compiles.

The same two shapes over `fixed` fields only are not judged here; ADR-0144 open item 12 refuses them on a card of its own after this release.

⛔ Not released alone: this ships in the same release as the record currency mode, its write refusals and the per-row display.
9 changes: 8 additions & 1 deletion content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ is executed. `POST /analytics/sql` refuses the same keys. To order by a member,
<Callout type="info">
**`fields[]` is the resolved presentation surface — read it first.** Each entry carries
`name` and `type` and, when the producer declares them, `label`, `format`, `currency`,
`percentScale`, `builtinAggregate`, `aggregate`, `nullLabel` and `polarity` (which direction
`currencyField`, `percentScale`, `builtinAggregate`, `aggregate`, `nullLabel` and `polarity` (which direction
of a measure's change is good news, relayed from the dataset measure): the optional members
`AnalyticsResultResponseSchema` declares (`packages/spec/src/api/analytics.zod.ts`),
mirrored member for member by `IAnalyticsService.query`'s `AnalyticsResult` and bound to
Expand All @@ -500,6 +500,13 @@ tenant default currency (the `localization.currency` setting), if one is set. A
so its column shows the tenant default. The cube query on this page carries no column
`currency`.

A measure column over a `currencyMode: 'record'` amount has one currency per row, so it
carries `currencyField` instead of `currency`: the name of the dimension column, in the same
answer, that groups by the amount's code field (`currencyConfig.currencyField`). Each row's
currency is that row's value of that column. A `sum`, `avg`, `min` or `max` over such an
amount is refused `INVALID_FIELD` / 400 unless the query groups by the code field, on the
dataset query and the cube query alike; a `count` is served.

The dataset query also states each measure column's `aggregate` (`count`, `sum`, `avg`,
`min`, `max`, `count_distinct`), whether or not the author labelled the measure, so a
chart can tell a count from a sum. `builtinAggregate` is narrower: it is present only
Expand Down

Large diffs are not rendered by default.

86 changes: 83 additions & 3 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import { measureResultType } from './measure-result-type.js';
// `ensureCube`, ahead of both strategies, as the dataset door refuses it at
// compile.
import { assertCubeMeasureFieldTypesAccepted, type MeasureColumn } from './cube-measure-field-type-door.js';
import { AMOUNT_AGGREGATES, assertRecordCurrencyAggregatesGrouped } from './cube-record-currency-door.js';
import type { AnalyticsStrategy, AnalyticsDriverCapabilities, StrategyContext, DatasetScopedStrategyContext, DatasetScope, ReadScopeFilterJudge } from './strategies/types.js';
import { NativeSQLStrategy } from './strategies/native-sql-strategy.js';
import { ObjectQLStrategy } from './strategies/objectql-strategy.js';
Expand Down Expand Up @@ -1134,6 +1135,12 @@ export interface AnalyticsServiceConfig {
* 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.
* [ADR-0144 D2] `currencyField` is the code field of a `record`-mode
* amount: answer `currencyConfig.currencyField` ONLY when
* `currencyConfig.currencyMode` is `'record'`, and `undefined` otherwise.
* Such an amount has one currency per ROW, so its column carries the
* `currencyField` pointer instead of a `currency`, and the cube door
* refuses an aggregate of it that does not group by that field.
* - 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 @@ -1163,7 +1170,7 @@ export interface AnalyticsServiceConfig {
sourceFieldMeta?: (
object: string,
field: string,
) => { type?: string; multiple?: boolean; defaultCurrency?: string; max?: number } | undefined;
) => { type?: string; multiple?: boolean; defaultCurrency?: string; currencyField?: string; max?: number } | undefined;
/**
* [#15684] The SQL dialect of the datasource backing `object` — `'sqlite'`,
* `'postgres'`, `'mysql'`, or `undefined` when the host cannot answer.
Expand Down Expand Up @@ -2549,6 +2556,13 @@ export class AnalyticsService implements IAnalyticsService {
if (typeof meta?.type !== 'string' || meta.type === '') return undefined;
return { type: meta.type, multiple: meta.multiple === true };
},
// [ADR-0144, M0-3] …and the declared currency off the same hook, so the
// compile-time currency guards judge exactly what the column would print.
declaredCurrency: (object: string, field: string) => {
const meta = this.sourceFieldMeta?.(object, field);
if (!meta) return undefined;
return { type: meta.type, defaultCurrency: meta.defaultCurrency, currencyField: meta.currencyField };
},
});
}

Expand Down Expand Up @@ -2711,6 +2725,10 @@ export class AnalyticsService implements IAnalyticsService {
// carries the same `internal` fields the published ones do.
this.assertNoInternalFieldsQueried(q, compiled.cube, previewScope);
await this.assertFieldsReadable(q, compiled.cube, previewScope, context);
// [ADR-0144 D6] …and the record-currency door, which the published
// path meets in `ensureCube`: a draft preview must not serve the
// sum across currencies that the published dataset refuses.
this.assertRecordCurrencyGrouped(q, compiled.cube);
return evaluateAnalyticsQueryOverRows(
q,
compiled.cube,
Expand Down Expand Up @@ -3158,10 +3176,26 @@ export class AnalyticsService implements IAnalyticsService {
// 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 };
//
// [ADR-0144 D2] A `record`-mode amount has one currency per ROW, held
// in its code field, so no single code describes the column and no
// step of the chain is asked. The column names, by `currencyField`,
// the selected dimension that groups by that code field: each row's
// currency is that row's value of it. Read off the dataset and the
// declaration like every key here, never off the rows. The cube door
// has already refused an amount aggregate that drops the grouping; a
// column left without the dimension gets neither key, rather than a
// label that is wrong for every other row. A `count` over the amount
// reads no amount, so it gets neither key either, grouped or not.
const fc = f as { currency?: string; currencyField?: string };
const mc = m as { currency?: string };
const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : undefined;
if (fc.currency == null) {
if (meta?.currencyField !== undefined) {
const codeColumn = AMOUNT_AGGREGATES.has(m.aggregate ?? '')
? this.selectedDimensions(dataset, selection).find((d) => d.field === meta.currencyField)?.name
: undefined;
if (fc.currencyField == null && codeColumn !== undefined) fc.currencyField = codeColumn;
} else if (fc.currency == null) {
const monetary = !!mc.currency || meta?.type === 'currency';
if (monetary) {
const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
Expand Down Expand Up @@ -3483,6 +3517,10 @@ export class AnalyticsService implements IAnalyticsService {
// [#21044] …and a measure's aggregate must be one the aggregate ×
// field-type table accepts for its column's declared type.
this.assertMeasureFieldTypes(query, cube);
// [ADR-0144 D6] …and an amount aggregate over a record-currency field
// must group by its code field: this ad-hoc exit is the one
// `ObjectStackDataSource.aggregate()` reaches (`${field}_${fn}`).
this.assertRecordCurrencyGrouped(query, cube);
// [#5669] …and the `where`'s, third and last of the three request keys that
// carry a field name. Its members are read from the filter TREE, not from
// `cube.dimensions` — which on this path was minted from this very query,
Expand Down Expand Up @@ -3566,6 +3604,7 @@ export class AnalyticsService implements IAnalyticsService {
// [#21044] Judged on the AUGMENTED cube: a suffix-inferred measure and an
// authored one are one population for the aggregate × field-type table.
this.assertMeasureFieldTypes(query, augmented);
this.assertRecordCurrencyGrouped(query, augmented);
// [#5669] The `where` gate resolves a filter member through dimensions AND
// measures (that is what the strategies do for a filter member), so it is
// handed the AUGMENTED cube — a caller filtering on a suffix-inferred
Expand All @@ -3582,6 +3621,7 @@ export class AnalyticsService implements IAnalyticsService {
this.assertDimensionFields(query, cube, Object.keys(cube.dimensions));
this.assertDimensionsGroupScalarColumns(query, cube);
this.assertMeasureFieldTypes(query, cube);
this.assertRecordCurrencyGrouped(query, cube);
this.assertWhereFields(query, cube, Object.keys(cube.dimensions));
}
}
Expand Down Expand Up @@ -3624,6 +3664,46 @@ export class AnalyticsService implements IAnalyticsService {
);
}

/**
* [ADR-0144 D6] Refuse a `sum` / `avg` / `min` / `max` measure over a
* `record`-mode amount whose query does not group by the amount's code field
* — `INVALID_FIELD` / 400, naming the member the caller wrote — before either
* strategy reads anything. The rule and the envelope are
* {@link assertRecordCurrencyAggregatesGrouped}'s
* (`cube-record-currency-door.ts`); this method supplies the answers only the
* service has: the measure column {@link declaredMeasureColumn} resolves
* (the #21044 door's resolver), each dimension's column by the same
* {@link declaredMemberEntry} lookup and hop resolver, and the code field off
* {@link AnalyticsServiceConfig.sourceFieldMeta}.
*
* Runs after the #21044 door, so a pair the field-type table refuses is
* answered as that first. Same stand-downs: no `sourceFieldMeta`, or a cube
* whose `sql` is not a bare object name.
*/
private assertRecordCurrencyGrouped(query: AnalyticsQuery, cube: Cube): void {
const fieldMeta = this.sourceFieldMeta;
if (!fieldMeta) return;
const object = typeof cube.sql === 'string' ? cube.sql.trim() : '';
if (!object || !BARE_IDENTIFIER.test(object)) return;
assertRecordCurrencyAggregatesGrouped(
query,
cube.name,
(member) => declaredMeasureColumn(cube, member, object, this.hopReference),
(member) => {
const entry = declaredMemberEntry(cube, member, 'dimension');
const sql = (entry ? (typeof entry.sql === 'string' ? entry.sql : '') : member).trim();
if (BARE_IDENTIFIER.test(sql)) return { object, column: sql, path: sql };
if (!IDENTIFIER_PATH.test(sql)) return null;
return {
object: columnObjectOf(cube, object, sql, this.hopReference),
column: sql.slice(sql.lastIndexOf('.') + 1),
path: sql,
};
},
(o, field) => fieldMeta(o, field)?.currencyField,
);
}

/**
* [#20807] Refuse a GROUPED dimension whose column is a declared
* structured-JSON field — `INVALID_FIELD` / 400, naming the member the caller
Expand Down
115 changes: 115 additions & 0 deletions packages/services/service-analytics/src/cube-record-currency-door.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0144 D6] The cube door's record-currency judgment: a `sum` / `avg` /
* `min` / `max` measure over a `currencyMode: 'record'` amount is refused
* `INVALID_FIELD` / 400 unless the same query groups by that amount's code
* field (`currencyConfig.currencyField`), ahead of strategy selection.
*
* ## The shape this closes
*
* A record-mode amount is a bare number (ADR-0104 D1) whose currency lives in a
* sibling code field, one per row. An aggregate that does not group by the code
* adds, averages or orders numbers in different currencies: the measurement
* round on #8345 (probe A) saw a USD row of 100 and an EUR row of 100 answer
* `200`, labelled with the tenant currency. Grouped by the code, every group
* holds one currency and the answer is right (probe C); the per-row label is
* the result column's `currencyField` (`AnalyticsResult.fields`,
* `@objectstack/spec/contracts`).
*
* ## Where it stands
*
* In `AnalyticsService.ensureCube`, on every path out of it (the inferred,
* augmented and declared cubes), right after the #21044 measure × field-type
* door (`cube-measure-field-type-door.ts`): ahead of both strategies, for
* `query()` and `generateSql()` alike. The ADR-0037 draft-preview branch of
* `queryDataset` evaluates in memory and never reaches `ensureCube`, so its
* per-query gate calls the same judgment. A dataset
* selection reaches it through `DatasetExecutor`, so its primary grid, every
* `totals` grouping (the grand total `[]` included) and the `compareTo` pass
* are judged one by one: a grouping that drops the code is a sum across
* currencies like any other.
*
* ## What it judges
*
* - Every `measures` entry that resolves to a cube measure of type `sum`,
* `avg`, `min` or `max` (ADR-0144 open item 10). `count` and
* `count_distinct` read no amount and are not judged.
* - The measure's column, located on the object that declares it by the
* caller's resolver (`declaredMeasureColumn`, the one the #21044 door reads).
* - "Grouped by the code" means a `dimensions` entry whose column is the code
* field on that SAME object. A time dimension is no currency group, and a
* `where` that happens to pin one code is not judged: the rule is the
* grouping, so an answer's label never depends on what a filter selected.
*
* ## Not judged — "cannot answer, do not block"
*
* - A host that wires no `sourceFieldMeta`, or a cube whose `sql` is not a
* bare object name (the caller stands down for both).
* - A member that resolves to no declared measure, a `sql` that is not a
* column reference, or a column whose declaration names no `record` mode.
*
* ## The envelope
*
* `INVALID_FIELD` / 400 through `invalidMemberError` (ADR-0112), with the
* column and its object attached, as the #21044 door attaches them: a verdict
* about one member the request named. The words carry the prescription:
* group by the code field. A consolidated total aggregates the stored home
* field instead (ADR-0144 D6), which the write-time projection (D5) fills.
*/

import type { AnalyticsQuery } from '@objectstack/spec/contracts';
import type { MeasureColumn } from './cube-measure-field-type-door.js';
import { invalidMemberError } from './dataset-refusal.js';

/** The aggregates that read the amount itself (ADR-0144 open item 10). */
export const AMOUNT_AGGREGATES: ReadonlySet<string> = new Set(['sum', 'avg', 'min', 'max']);

/**
* Refuse the first `measures` entry of `query` that aggregates a record-mode
* amount without grouping by its code field — `INVALID_FIELD` / 400. See the
* module header.
*
* @param measureOf - The cube measure a `measures` entry resolves to: its
* `type`, and the column it aggregates (`null` when its `sql` is not a column
* reference) — or `undefined` when it resolves to no declared measure.
* @param dimensionColumnOf - The column a `dimensions` entry groups by, located
* the same way, or `null` when this door cannot pin one.
* @param recordCurrencyFieldOf - The `currencyConfig.currencyField` of a column
* declared `currencyMode: 'record'`, or `undefined` for any other column.
*/
export function assertRecordCurrencyAggregatesGrouped(
query: AnalyticsQuery,
cubeName: string,
measureOf: (member: string) => { type: unknown; column: MeasureColumn | null } | undefined,
dimensionColumnOf: (member: string) => MeasureColumn | null,
recordCurrencyFieldOf: (object: string, field: string) => string | undefined,
): void {
const measures = query.measures ?? [];
if (measures.length === 0) return;
let grouped: Array<MeasureColumn | null> | undefined;
for (const member of measures) {
const measure = measureOf(member);
if (!measure?.column || typeof measure.type !== 'string' || !AMOUNT_AGGREGATES.has(measure.type)) continue;
const target = measure.column;
const codeField = recordCurrencyFieldOf(target.object, target.column);
if (codeField === undefined) continue;
grouped ??= (query.dimensions ?? []).map(dimensionColumnOf);
if (grouped.some((d) => d?.object === target.object && d.column === codeField)) continue;

const codePath = target.path === target.column
? codeField
: `${target.path.slice(0, target.path.lastIndexOf('.') + 1)}${codeField}`;
const err = invalidMemberError(
`Measure '${member}' on cube '${cubeName}' takes the ${measure.type} of field '${target.path}', `
+ `a record-currency amount whose currency is each record's '${codeField}', but the query does not `
+ `group by '${codePath}', so the query was NOT run. Add the dimension over '${codePath}' to the `
+ `query, which answers one row per currency. Without the grouping the ${measure.type} would `
+ 'combine amounts in different currencies into one number.',
{ member, param: 'measures', cube: cubeName },
) as Error & { field?: string; object?: string };
err.field = target.path;
err.object = target.object;
throw err;
}
}
Loading
Loading