Skip to content

Commit 0c2c5ba

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-i9j2m3
Claude-Session: https://claude.ai/code/session_01Utp6CaqVRa7ow6CjE1JwaG Co-authored-by: Claude <noreply@anthropic.com>
2 parents 355edc0 + 57c51aa commit 0c2c5ba

21 files changed

Lines changed: 1881 additions & 21 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/formula': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
feat(objectql): a record-currency amount is refused without its code, refused in an ungrouped raw aggregate, and refused under a summary roll-up
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **A `record`-mode amount is stored with its currency code (ADR-0144 D8 rule 4).** An insert or update that would store a value in a `currency` field declaring `currencyConfig: { currencyMode: 'record', currencyField }` while that code field is blank (absent, `null` or white space) is refused `ERR_CURRENCY_CODE_REQUIRED` / 400, with `field` naming the code field and `amountField` the amount. The server never fills a currency in. On an update, only a write that carries the amount or its code is judged, against the stored row with the payload laid over it, so an edit of another field never meets it. The `validate()` preview (`validateData`) answers the same refusal as the row's finding for an insert and for a by-id update, so a dry run does not pass a row the write refuses. The code is registered under `@objectstack/objectql` in the ADR-0112 error-code ledger.
13+
- **A raw aggregate over a `record`-mode amount groups by its code field (ADR-0144 D6).** `sum`, `avg`, `min` and `max` over such a field through `engine.aggregate` are refused `INVALID_FIELD` / 400 unless `groupBy` names the code field; grouped, each currency totals on its own. `count` and `count_distinct` are not judged. Before, USD 100 and EUR 100 summed to 200 under one label.
14+
- **A `summary` roll-up over a `record`-mode amount is refused at registration (ADR-0144 D6).** `sum` / `avg` / `min` / `max` of such a child field is refused `INVALID_METADATA` / 422 (`RecordCurrencySummaryRefusedError`) at whichever object's registration completes the pair. Before, child rows of USD 100 and JPY 15000 rolled up to 15100. Use `count`, or roll up a stored home-currency amount.
15+
- **`inferFormulaReturn` carries a record currency (`@objectstack/formula`).** A formula over a `record`-mode amount now declares `returnType: 'currency'` with `currencyConfig: { currencyMode: 'record', currencyField }`, the code field its sources name, instead of a tenant-default (`dynamic`) amount. Two record-mode amounts naming different code fields, or a record-mode amount meeting a `fixed` or `dynamic` one, are not one currency and are not stamped as money.
16+
- **Nothing to migrate.** `currencyMode: 'record'` is new; no field declared before it meets any of these refusals.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-analytics': minor
4+
---
5+
6+
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)
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
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.
12+
13+
- `AnalyticsResult.fields[].currencyField` (`@objectstack/spec/contracts`) and the same key on `AnalyticsResultResponseSchema` (`@objectstack/spec/api`).
14+
- 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.
15+
16+
Two compile-time refusals, `DATASET_INVALID` / 400, on the record mode alone, so nothing that compiled before is refused:
17+
18+
- a measure `currency` over a record-mode amount, whose currency is per row, including on a derived measure that resolves to one;
19+
- 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.
20+
21+
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.
22+
23+
⛔ Not released alone: this ships in the same release as the record currency mode, its write refusals and the per-row display.

‎content/docs/api/data-api.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ is executed. `POST /analytics/sql` refuses the same keys. To order by a member,
474474
<Callout type="info">
475475
**`fields[]` is the resolved presentation surface — read it first.** Each entry carries
476476
`name` and `type` and, when the producer declares them, `label`, `format`, `currency`,
477-
`percentScale`, `builtinAggregate`, `aggregate`, `nullLabel` and `polarity` (which direction
477+
`currencyField`, `percentScale`, `builtinAggregate`, `aggregate`, `nullLabel` and `polarity` (which direction
478478
of a measure's change is good news, relayed from the dataset measure): the optional members
479479
`AnalyticsResultResponseSchema` declares (`packages/spec/src/api/analytics.zod.ts`),
480480
mirrored member for member by `IAnalyticsService.query`'s `AnalyticsResult` and bound to
@@ -500,6 +500,13 @@ tenant default currency (the `localization.currency` setting), if one is set. A
500500
so its column shows the tenant default. The cube query on this page carries no column
501501
`currency`.
502502

503+
A measure column over a `currencyMode: 'record'` amount has one currency per row, so it
504+
carries `currencyField` instead of `currency`: the name of the dimension column, in the same
505+
answer, that groups by the amount's code field (`currencyConfig.currencyField`). Each row's
506+
currency is that row's value of that column. A `sum`, `avg`, `min` or `max` over such an
507+
amount is refused `INVALID_FIELD` / 400 unless the query groups by the code field, on the
508+
dataset query and the cube query alike; a `count` is served.
509+
503510
The dataset query also states each measure column's `aggregate` (`count`, `sum`, `avg`,
504511
`min`, `max`, `count_distinct`), whether or not the author labelled the measure, so a
505512
chart can tell a count from a sum. `builtinAggregate` is narrower: it is present only

‎packages/formula/src/formula-return.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,40 @@ describe('CONTROL — a numeric formula stays `number`', () => {
169169
expect(inferFormulaReturn('record.amount *', FIELDS)).toEqual({});
170170
});
171171
});
172+
173+
describe('a formula over a record-mode amount carries the record currency (ADR-0144 D2)', () => {
174+
const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const;
175+
const RECORD_FIELDS: Readonly<Record<string, FormulaSourceField>> = {
176+
currency_code: { type: 'text' },
177+
other_code: { type: 'text' },
178+
amount: { type: 'currency', currencyConfig: RECORD },
179+
tax: { type: 'currency', currencyConfig: RECORD },
180+
fee: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'other_code' } },
181+
budget: { type: 'currency' },
182+
usd: { type: 'currency', currencyConfig: USD },
183+
quantity: { type: 'number' },
184+
};
185+
186+
it('stamps `record` mode with the source amount\'s code field, never the tenant default', () => {
187+
expect(inferFormulaReturn('record.amount * 0.1', RECORD_FIELDS))
188+
.toEqual({ returnType: 'currency', currencyConfig: RECORD });
189+
expect(inferFormulaReturn('record.amount + record.tax', RECORD_FIELDS))
190+
.toEqual({ returnType: 'currency', currencyConfig: RECORD });
191+
expect(inferFormulaReturn('round(record.amount * record.quantity)', RECORD_FIELDS))
192+
.toEqual({ returnType: 'currency', currencyConfig: RECORD });
193+
});
194+
195+
it('an amount over its own currency is a plain ratio', () => {
196+
expect(inferFormulaReturn('record.tax / record.amount', RECORD_FIELDS).returnType).not.toBe('currency');
197+
});
198+
199+
it('two different currencies meeting are not one amount', () => {
200+
for (const source of [
201+
'record.amount + record.fee', // two code fields
202+
'record.amount + record.budget', // record + the tenant default
203+
'record.amount + record.usd', // record + fixed USD
204+
]) {
205+
expect(inferFormulaReturn(source, RECORD_FIELDS).returnType, source).not.toBe('currency');
206+
}
207+
});
208+
});

‎packages/formula/src/formula-return.ts‎

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -79,15 +79,25 @@ export interface FormulaSourceField {
7979
*
8080
* - `returnType` — absent when no concrete type can be proven.
8181
* - `currencyConfig` — present only for a `currency` result whose currency is
82-
* `fixed`. A `dynamic` result (the tenant default currency) declares no config,
83-
* exactly as a `dynamic` currency field may carry none.
82+
* `fixed`, or `record` (ADR-0144 D2: each record's own currency, carried as
83+
* the same `currencyField` its source amounts name). A `dynamic` result (the
84+
* tenant default currency) declares no config, exactly as a `dynamic`
85+
* currency field may carry none.
8486
*/
8587
export interface FormulaReturnDeclaration {
8688
readonly returnType?: NonNullable<Field['returnType']>;
87-
readonly currencyConfig?: { readonly currencyMode: 'fixed'; readonly defaultCurrency: string };
89+
readonly currencyConfig?:
90+
| { readonly currencyMode: 'fixed'; readonly defaultCurrency: string }
91+
| { readonly currencyMode: 'record'; readonly currencyField: string };
8892
}
8993

90-
type MoneyCurrency = { readonly mode: 'dynamic' } | { readonly mode: 'fixed'; readonly code: string };
94+
type MoneyCurrency =
95+
| { readonly mode: 'dynamic' }
96+
| { readonly mode: 'fixed'; readonly code: string }
97+
// [ADR-0144 D2] Each record's own currency, the code in the sibling field
98+
// `field` names. Two record-mode amounts are one currency only when they
99+
// name the same code field.
100+
| { readonly mode: 'record'; readonly field: string };
91101

92102
type Dim =
93103
| { readonly kind: 'money'; readonly currency: MoneyCurrency }
@@ -125,8 +135,9 @@ function isNode(v: unknown): v is Node {
125135
}
126136

127137
function sameCurrency(a: MoneyCurrency, b: MoneyCurrency): boolean {
128-
if (a.mode === 'dynamic' || b.mode === 'dynamic') return a.mode === b.mode;
129-
return a.code === b.code;
138+
if (a.mode === 'fixed' && b.mode === 'fixed') return a.code === b.code;
139+
if (a.mode === 'record' && b.mode === 'record') return a.field === b.field;
140+
return a.mode === b.mode;
130141
}
131142

132143
/**
@@ -137,8 +148,16 @@ function sameCurrency(a: MoneyCurrency, b: MoneyCurrency): boolean {
137148
function currencyOf(config: unknown): MoneyCurrency | null {
138149
const parsed = CurrencyConfigSchema.safeParse(config ?? {});
139150
if (!parsed.success) return null;
140-
if (parsed.data.currencyMode !== 'fixed') return { mode: 'dynamic' };
141-
return parsed.data.defaultCurrency === undefined ? null : { mode: 'fixed', code: parsed.data.defaultCurrency };
151+
if (parsed.data.currencyMode === 'fixed') {
152+
return parsed.data.defaultCurrency === undefined ? null : { mode: 'fixed', code: parsed.data.defaultCurrency };
153+
}
154+
// [ADR-0144 D2] A `record`-mode amount is in the currency its sibling code
155+
// field holds — never the tenant default a `dynamic` stamp would claim. The
156+
// schema requires the pointer under `record`, so a parsed config carries it.
157+
if (parsed.data.currencyMode === 'record') {
158+
return parsed.data.currencyField ? { mode: 'record', field: parsed.data.currencyField } : null;
159+
}
160+
return { mode: 'dynamic' };
142161
}
143162

144163
/** Is `d` a number that is not an amount (a quantity or a constant)? */
@@ -256,8 +275,10 @@ function sourceOf(input: ExprInput): string | null {
256275
*
257276
* - Provably money ⇒ `{ returnType: 'currency' }`, plus
258277
* `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when the
259-
* source amounts are in one fixed currency. Sources in two different
260-
* currencies are not provably one amount, and fall through.
278+
* source amounts are in one fixed currency, or
279+
* `currencyConfig: { currencyMode: 'record', currencyField }` when they are
280+
* record-mode amounts naming one code field (ADR-0144 D2). Sources in two
281+
* different currencies are not provably one amount, and fall through.
261282
* - Otherwise ⇒ the {@link inferExpressionType} answer as `returnType`
262283
* (`number` / `text` / `boolean` / `date`), or `{}` when that is `unknown` —
263284
* unchanged from before this function existed, so a numeric formula stays
@@ -281,9 +302,13 @@ export function inferFormulaReturn(
281302
if (celType !== null && MONEY_CEL_TYPES.has(celType)) {
282303
const dim = dimOf(parseCelToAst(source), fields);
283304
if (dim.kind === 'money') {
284-
return dim.currency.mode === 'fixed'
285-
? { returnType: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: dim.currency.code } }
286-
: { returnType: 'currency' };
305+
if (dim.currency.mode === 'fixed') {
306+
return { returnType: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: dim.currency.code } };
307+
}
308+
if (dim.currency.mode === 'record') {
309+
return { returnType: 'currency', currencyConfig: { currencyMode: 'record', currencyField: dim.currency.field } };
310+
}
311+
return { returnType: 'currency' };
287312
}
288313
}
289314
}

‎packages/objectql/src/engine-validate.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import type { WriteObservabilityOptions } from '@objectstack/spec/contracts';
5050
import { validateRecordInScope, normalizeBlankTypedValues, normalizeNumericStringValues, ValidationError, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js';
5151
import { evaluateValidationRules, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields } from './validation/rule-validator.js';
5252
import { createBatchSummaryAccumulator } from './summary-batch-accumulation.js';
53+
import { assertRecordCurrencyCodes, RecordCurrencyCodeRequiredError } from './validation/record-currency-code.js';
5354
import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from './engine-update-dispatch.js';
5455
import {
5556
droppedFieldEvents,
@@ -393,11 +394,15 @@ export async function validate(
393394
// `undefined` = no stored row: the row is judged on the patch alone, as
394395
// it was before these reads existed.
395396
const storedRows: Array<Record<string, unknown> | undefined> = [];
397+
// The by-id address each stored row was read from, for a refusal that names the record.
398+
const storedIds: unknown[] = [];
396399
for (const submitted of submittedRows) {
397400
let stored: Record<string, unknown> | undefined;
401+
let storedId: unknown;
398402
if (mode === 'update' && submitted && typeof submitted === 'object') {
399403
const dispatch = resolveEngineUpdateDispatch(submitted as EngineUpdateDispatchData, undefined);
400404
if (dispatch.kind === 'by-id') {
405+
storedId = dispatch.id;
401406
const visible = await this.findOne(object, {
402407
where: { id: dispatch.id },
403408
context: { ...(options?.context ?? {}), [RAW_FILE_VALUES_CONTEXT_KEY]: true },
@@ -417,6 +422,7 @@ export async function validate(
417422
}
418423
}
419424
storedRows.push(stored);
425+
storedIds.push(storedId);
420426
}
421427
// The merged view each rule evaluates, and the one the reference reads
422428
// below key on: the write reads a traversed FK off the same merge
@@ -543,6 +549,21 @@ export async function validate(
543549
// preview admits exactly what the write would.
544550
keptOptionValues: options?.context?.keptOptionValues,
545551
});
552+
// [ADR-0144 D8 rule 4] The write's blank-code refusal, at the write's
553+
// step and on the write's image: the insert row as stored, or the stored
554+
// row with the patch over it (`judgedViews`, the by-id update's
555+
// `priorRecord ⊕ payload`), judging only the pairs the patch touches.
556+
// An update with no stored row here (a predicate dispatch, an id this
557+
// caller cannot read) is not judged: the image the write judges is a
558+
// row the preview does not hold. A code column the read door withholds
559+
// from this caller is absent from the stored row, so the preview reads
560+
// it as blank — stricter than the write, never laxer.
561+
if (mode === 'insert' || storedRows[i]) {
562+
assertRecordCurrencyCodes(
563+
object, (schemaForValidation as { fields?: unknown } | undefined)?.fields,
564+
mode === 'insert' ? row : judgedViews[i], mode, mode === 'insert' ? undefined : row, storedIds[i],
565+
);
566+
}
546567
warnings.push(...evaluateValidationRules(schemaForValidation as any, row, mode, {
547568
// [#22445] The stored row, when this row named one: the by-id
548569
// update's `previous: priorRecord`. Absent, the record is the patch.
@@ -571,6 +592,10 @@ export async function validate(
571592
if (e instanceof ValidationError) {
572593
return { valid: false, errors: e.fields.map((f) => ({ ...f })), warnings };
573594
}
595+
// The write throws this refusal whole; the preview answers it as the row's finding.
596+
if (e instanceof RecordCurrencyCodeRequiredError) {
597+
return { valid: false, errors: [{ field: e.field, code: e.code, message: e.message }], warnings };
598+
}
574599
throw e;
575600
}
576601
batchSummaries?.accept(row);

0 commit comments

Comments
 (0)