diff --git a/.changeset/27186-record-currency-guards.md b/.changeset/27186-record-currency-guards.md new file mode 100644 index 00000000000..e70c832ae91 --- /dev/null +++ b/.changeset/27186-record-currency-guards.md @@ -0,0 +1,16 @@ +--- +'@objectstack/objectql': patch +'@objectstack/formula': patch +'@objectstack/spec': patch +--- + +feat(objectql): a record-currency amount is refused without its code, refused in an ungrouped raw aggregate, and refused under a summary roll-up + +Clause-②: yes (widening) +Metadata-protocol: none + +- **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. +- **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. +- **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. +- **`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. +- **Nothing to migrate.** `currencyMode: 'record'` is new; no field declared before it meets any of these refusals. diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index c3acf40ca8a..5ce1f5c4517 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -347,7 +347,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +349 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +350 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | | **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds | ### Allowed Values: `FlowInnerRefusal.code` @@ -479,6 +479,7 @@ const result = AutomationApiErrorCode.parse(data); * `ERR_AUTONUMBER_COLLISION` * `ERR_BULK_RESULT_MISMATCH` * `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE` +* `ERR_CURRENCY_CODE_REQUIRED` * `ERR_DATASOURCE_UNAVAILABLE` * `ERR_DRIVER_CONNECT` * `ERR_FILE_CONSTRAINT` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index b522fa9201e..9ddc208b598 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +349 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +350 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -167,6 +167,7 @@ const result = ApiErrorSchema.parse(data); * `ERR_AUTONUMBER_COLLISION` * `ERR_BULK_RESULT_MISMATCH` * `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE` +* `ERR_CURRENCY_CODE_REQUIRED` * `ERR_DATASOURCE_UNAVAILABLE` * `ERR_DRIVER_CONNECT` * `ERR_FILE_CONSTRAINT` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index f35d01d46e7..6a0021e1ae4 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -334,6 +334,7 @@ const result = ErrorCode.parse(data); * `ERR_AUTONUMBER_COLLISION` * `ERR_BULK_RESULT_MISMATCH` * `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE` +* `ERR_CURRENCY_CODE_REQUIRED` * `ERR_DATASOURCE_UNAVAILABLE` * `ERR_DRIVER_CONNECT` * `ERR_FILE_CONSTRAINT` diff --git a/packages/formula/src/formula-return.test.ts b/packages/formula/src/formula-return.test.ts index 71563857550..9ccf5de24cc 100644 --- a/packages/formula/src/formula-return.test.ts +++ b/packages/formula/src/formula-return.test.ts @@ -169,3 +169,40 @@ describe('CONTROL — a numeric formula stays `number`', () => { expect(inferFormulaReturn('record.amount *', FIELDS)).toEqual({}); }); }); + +describe('a formula over a record-mode amount carries the record currency (ADR-0144 D2)', () => { + const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const; + const RECORD_FIELDS: Readonly> = { + currency_code: { type: 'text' }, + other_code: { type: 'text' }, + amount: { type: 'currency', currencyConfig: RECORD }, + tax: { type: 'currency', currencyConfig: RECORD }, + fee: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'other_code' } }, + budget: { type: 'currency' }, + usd: { type: 'currency', currencyConfig: USD }, + quantity: { type: 'number' }, + }; + + it('stamps `record` mode with the source amount\'s code field, never the tenant default', () => { + expect(inferFormulaReturn('record.amount * 0.1', RECORD_FIELDS)) + .toEqual({ returnType: 'currency', currencyConfig: RECORD }); + expect(inferFormulaReturn('record.amount + record.tax', RECORD_FIELDS)) + .toEqual({ returnType: 'currency', currencyConfig: RECORD }); + expect(inferFormulaReturn('round(record.amount * record.quantity)', RECORD_FIELDS)) + .toEqual({ returnType: 'currency', currencyConfig: RECORD }); + }); + + it('an amount over its own currency is a plain ratio', () => { + expect(inferFormulaReturn('record.tax / record.amount', RECORD_FIELDS).returnType).not.toBe('currency'); + }); + + it('two different currencies meeting are not one amount', () => { + for (const source of [ + 'record.amount + record.fee', // two code fields + 'record.amount + record.budget', // record + the tenant default + 'record.amount + record.usd', // record + fixed USD + ]) { + expect(inferFormulaReturn(source, RECORD_FIELDS).returnType, source).not.toBe('currency'); + } + }); +}); diff --git a/packages/formula/src/formula-return.ts b/packages/formula/src/formula-return.ts index ebb5349f920..bb298a082b3 100644 --- a/packages/formula/src/formula-return.ts +++ b/packages/formula/src/formula-return.ts @@ -79,15 +79,25 @@ export interface FormulaSourceField { * * - `returnType` — absent when no concrete type can be proven. * - `currencyConfig` — present only for a `currency` result whose currency is - * `fixed`. A `dynamic` result (the tenant default currency) declares no config, - * exactly as a `dynamic` currency field may carry none. + * `fixed`, or `record` (ADR-0144 D2: each record's own currency, carried as + * the same `currencyField` its source amounts name). A `dynamic` result (the + * tenant default currency) declares no config, exactly as a `dynamic` + * currency field may carry none. */ export interface FormulaReturnDeclaration { readonly returnType?: NonNullable; - readonly currencyConfig?: { readonly currencyMode: 'fixed'; readonly defaultCurrency: string }; + readonly currencyConfig?: + | { readonly currencyMode: 'fixed'; readonly defaultCurrency: string } + | { readonly currencyMode: 'record'; readonly currencyField: string }; } -type MoneyCurrency = { readonly mode: 'dynamic' } | { readonly mode: 'fixed'; readonly code: string }; +type MoneyCurrency = + | { readonly mode: 'dynamic' } + | { readonly mode: 'fixed'; readonly code: string } + // [ADR-0144 D2] Each record's own currency, the code in the sibling field + // `field` names. Two record-mode amounts are one currency only when they + // name the same code field. + | { readonly mode: 'record'; readonly field: string }; type Dim = | { readonly kind: 'money'; readonly currency: MoneyCurrency } @@ -125,8 +135,9 @@ function isNode(v: unknown): v is Node { } function sameCurrency(a: MoneyCurrency, b: MoneyCurrency): boolean { - if (a.mode === 'dynamic' || b.mode === 'dynamic') return a.mode === b.mode; - return a.code === b.code; + if (a.mode === 'fixed' && b.mode === 'fixed') return a.code === b.code; + if (a.mode === 'record' && b.mode === 'record') return a.field === b.field; + return a.mode === b.mode; } /** @@ -137,8 +148,16 @@ function sameCurrency(a: MoneyCurrency, b: MoneyCurrency): boolean { function currencyOf(config: unknown): MoneyCurrency | null { const parsed = CurrencyConfigSchema.safeParse(config ?? {}); if (!parsed.success) return null; - if (parsed.data.currencyMode !== 'fixed') return { mode: 'dynamic' }; - return parsed.data.defaultCurrency === undefined ? null : { mode: 'fixed', code: parsed.data.defaultCurrency }; + if (parsed.data.currencyMode === 'fixed') { + return parsed.data.defaultCurrency === undefined ? null : { mode: 'fixed', code: parsed.data.defaultCurrency }; + } + // [ADR-0144 D2] A `record`-mode amount is in the currency its sibling code + // field holds — never the tenant default a `dynamic` stamp would claim. The + // schema requires the pointer under `record`, so a parsed config carries it. + if (parsed.data.currencyMode === 'record') { + return parsed.data.currencyField ? { mode: 'record', field: parsed.data.currencyField } : null; + } + return { mode: 'dynamic' }; } /** Is `d` a number that is not an amount (a quantity or a constant)? */ @@ -256,8 +275,10 @@ function sourceOf(input: ExprInput): string | null { * * - Provably money ⇒ `{ returnType: 'currency' }`, plus * `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when the - * source amounts are in one fixed currency. Sources in two different - * currencies are not provably one amount, and fall through. + * source amounts are in one fixed currency, or + * `currencyConfig: { currencyMode: 'record', currencyField }` when they are + * record-mode amounts naming one code field (ADR-0144 D2). Sources in two + * different currencies are not provably one amount, and fall through. * - Otherwise ⇒ the {@link inferExpressionType} answer as `returnType` * (`number` / `text` / `boolean` / `date`), or `{}` when that is `unknown` — * unchanged from before this function existed, so a numeric formula stays @@ -281,9 +302,13 @@ export function inferFormulaReturn( if (celType !== null && MONEY_CEL_TYPES.has(celType)) { const dim = dimOf(parseCelToAst(source), fields); if (dim.kind === 'money') { - return dim.currency.mode === 'fixed' - ? { returnType: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: dim.currency.code } } - : { returnType: 'currency' }; + if (dim.currency.mode === 'fixed') { + return { returnType: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: dim.currency.code } }; + } + if (dim.currency.mode === 'record') { + return { returnType: 'currency', currencyConfig: { currencyMode: 'record', currencyField: dim.currency.field } }; + } + return { returnType: 'currency' }; } } } diff --git a/packages/objectql/src/engine-validate.ts b/packages/objectql/src/engine-validate.ts index 5939b489c3b..7659524e34f 100644 --- a/packages/objectql/src/engine-validate.ts +++ b/packages/objectql/src/engine-validate.ts @@ -50,6 +50,7 @@ import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; import { validateRecordInScope, normalizeBlankTypedValues, normalizeNumericStringValues, ValidationError, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import { evaluateValidationRules, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields } from './validation/rule-validator.js'; import { createBatchSummaryAccumulator } from './summary-batch-accumulation.js'; +import { assertRecordCurrencyCodes, RecordCurrencyCodeRequiredError } from './validation/record-currency-code.js'; import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from './engine-update-dispatch.js'; import { droppedFieldEvents, @@ -393,11 +394,15 @@ export async function validate( // `undefined` = no stored row: the row is judged on the patch alone, as // it was before these reads existed. const storedRows: Array | undefined> = []; + // The by-id address each stored row was read from, for a refusal that names the record. + const storedIds: unknown[] = []; for (const submitted of submittedRows) { let stored: Record | undefined; + let storedId: unknown; if (mode === 'update' && submitted && typeof submitted === 'object') { const dispatch = resolveEngineUpdateDispatch(submitted as EngineUpdateDispatchData, undefined); if (dispatch.kind === 'by-id') { + storedId = dispatch.id; const visible = await this.findOne(object, { where: { id: dispatch.id }, context: { ...(options?.context ?? {}), [RAW_FILE_VALUES_CONTEXT_KEY]: true }, @@ -417,6 +422,7 @@ export async function validate( } } storedRows.push(stored); + storedIds.push(storedId); } // The merged view each rule evaluates, and the one the reference reads // below key on: the write reads a traversed FK off the same merge @@ -543,6 +549,21 @@ export async function validate( // preview admits exactly what the write would. keptOptionValues: options?.context?.keptOptionValues, }); + // [ADR-0144 D8 rule 4] The write's blank-code refusal, at the write's + // step and on the write's image: the insert row as stored, or the stored + // row with the patch over it (`judgedViews`, the by-id update's + // `priorRecord ⊕ payload`), judging only the pairs the patch touches. + // An update with no stored row here (a predicate dispatch, an id this + // caller cannot read) is not judged: the image the write judges is a + // row the preview does not hold. A code column the read door withholds + // from this caller is absent from the stored row, so the preview reads + // it as blank — stricter than the write, never laxer. + if (mode === 'insert' || storedRows[i]) { + assertRecordCurrencyCodes( + object, (schemaForValidation as { fields?: unknown } | undefined)?.fields, + mode === 'insert' ? row : judgedViews[i], mode, mode === 'insert' ? undefined : row, storedIds[i], + ); + } warnings.push(...evaluateValidationRules(schemaForValidation as any, row, mode, { // [#22445] The stored row, when this row named one: the by-id // update's `previous: priorRecord`. Absent, the record is the patch. @@ -571,6 +592,10 @@ export async function validate( if (e instanceof ValidationError) { return { valid: false, errors: e.fields.map((f) => ({ ...f })), warnings }; } + // The write throws this refusal whole; the preview answers it as the row's finding. + if (e instanceof RecordCurrencyCodeRequiredError) { + return { valid: false, errors: [{ field: e.field, code: e.code, message: e.message }], warnings }; + } throw e; } batchSummaries?.accept(row); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 932043014e4..2b9a80b31b7 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -304,6 +304,8 @@ import { count, rejectCredentialAggregation } from './engine-count-aggregate.js' import { assertGroupByNamesNoJsonStoredField } from './group-by-structured-json-door.js'; import { assertProjectionNamesAField } from './empty-projection-door.js'; import { assertAggregationFieldTypesAccepted } from './aggregate-field-type-door.js'; +import { assertRecordCurrencyAggregatesGrouped } from './record-currency-aggregate-door.js'; +import { assertRecordCurrencyCodes, touchesRecordCurrency } from './validation/record-currency-code.js'; import { assertAggregatedColumnsAreDistinct, assertDateBucketTargetsAreTemporal } from './aggregate-query-shape-door.js'; import { applyProtection, pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared'; import { SchemaRegistry, type ArtifactInstallScope } from './registry.js'; @@ -15423,6 +15425,9 @@ export class ObjectQL implements IObjectQLEngine { normalizeMultiValueFields(schemaForValidation, rows[i], 'include'); // [#22183] `keptOptionValues`: the option values an import kept for this write. validateRecordInScope(schemaForValidation, rows[i], 'insert', 'include', { mediaValueShapeStrict, valueShapeStrict, messages: msgCtx, onAdmittedValueShapeViolation, keptOptionValues: opCtx.context?.keptOptionValues }); + // [ADR-0144 D8 rule 4] A `record`-mode amount is stored with its + // currency code, or the row is refused (`ERR_CURRENCY_CODE_REQUIRED`). + assertRecordCurrencyCodes(object, (schemaForValidation as { fields?: unknown } | undefined)?.fields, rows[i], 'insert'); emitValidationAdvisories(options?.onValidationAdvisory, evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: msgCtx, parent: insertParentForRow?.(rows[i]), related: batchSummaries ? batchSummaries.overlay(insertRelatedForRow(rows[i])) : insertRelatedForRow(rows[i]), permissions: insertPermissionsFor(rows[i]) }), this.logger, object); await this.assertReferencesResolve( schemaForValidation, rows[i], suppliedPerRow[i], opCtx.context, msgCtx, @@ -17143,6 +17148,14 @@ export class ObjectQL implements IObjectQLEngine { // because those are already judged. See `ReadonlyValueScope`. normalizeMultiValueFields(updateSchema, hookContext.input.data as Record, 'only'); validateRecordInScope(updateSchema, hookContext.input.data as Record, 'update', 'only', { mediaValueShapeStrict, valueShapeStrict, messages: updateMsgCtx, onAdmittedValueShapeViolation }); + // [ADR-0144 D8 rule 4] The stored row with the FINAL payload laid + // over it keeps a currency code beside a record-mode amount the + // payload touches (`ERR_CURRENCY_CODE_REQUIRED`). + assertRecordCurrencyCodes( + object, (updateSchema as { fields?: unknown } | undefined)?.fields, + { ...(priorRecord ?? {}), ...(hookContext.input.data as Record) }, + 'update', hookContext.input.data as Record, hookContext.input.id, + ); // ── [#19989] The post-image seam on the BY-ID path ───────────── // // The by-id twin of the predicate-path call below, placed at the @@ -17462,6 +17475,20 @@ export class ObjectQL implements IObjectQLEngine { // N rows are written. normalizeMultiValueFields(updateSchema, hookContext.input.data as Record, 'only'); validateRecordInScope(updateSchema, hookContext.input.data as Record, 'update', 'only', { mediaValueShapeStrict, valueShapeStrict, messages: updateMsgCtx, onAdmittedValueShapeViolation }); + // [ADR-0144 D8 rule 4] The predicate-path twin: every matched row, + // with the FINAL payload laid over it, keeps a currency code beside + // a record-mode amount the payload touches. The matched rows are + // read only for such a payload (`readPriorRows` is the memo the + // seams below share). + { + const currencyPayload = hookContext.input.data as Record; + const currencyFields = (updateSchema as { fields?: unknown } | undefined)?.fields; + if (touchesRecordCurrency(currencyFields, currencyPayload)) { + for (const row of (await readPriorRows()) ?? []) { + assertRecordCurrencyCodes(object, currencyFields, { ...row, ...currencyPayload }, 'update', currencyPayload, row?.id); + } + } + } // ── [#19950] The post-image seam on the PREDICATE path ───────── // // An enforcement layer's write `check` must hold for EVERY row a @@ -20165,6 +20192,11 @@ export class ObjectQL implements IObjectQLEngine { // string on SQLite and a 500 on PostgreSQL; `sum` over one answered `0`, // `0` and a 500. Every row of the table is asked (see the door's header). assertAggregationFieldTypesAccepted(object, this._registry.getObject(object), query.aggregations); + // [ADR-0144 D6] …and a `sum` / `avg` / `min` / `max` over a `record`-mode + // amount that does not group by its code field: the rows hold amounts in + // different currencies, which add into one number under one label. + // After the type door, so a type it refuses keeps that door's words. + assertRecordCurrencyAggregatesGrouped(object, this._registry.getObject(object), query.groupBy, query.aggregations); // [#25818] …and the two shapes that answered a plausible wrong table: a // `dateGranularity` over a field declared neither a date nor a datetime // (every row in one null bucket), and two columns of the aggregated row diff --git a/packages/objectql/src/record-currency-aggregate-door.ts b/packages/objectql/src/record-currency-aggregate-door.ts new file mode 100644 index 00000000000..d43a2214e1c --- /dev/null +++ b/packages/objectql/src/record-currency-aggregate-door.ts @@ -0,0 +1,245 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D6] A raw aggregate over a `record`-mode amount refuses unless it + * groups by that amount's code field; a `summary` roll-up over one refuses at + * registration. + * + * ## What ran before + * + * The measurement round on epic #8345 (comment 6041378776) modelled a record + * currency the way authors do today, as a sibling text field, and probed a + * real engine. `sum` over a USD row of 100 and an EUR row of 100 answered + * `200` under one currency label (PROBE-A); a `summary` roll-up over child + * rows of USD 100 and JPY 15000 stored `15100`, and `avg` `7550` (PROBE-R). + * Grouped by the code, every per-group value was right (PROBE-C). A declared + * `record` mode makes the mixed rows knowable, so the engine refuses the two + * shapes that add them. + * + * ## Which aggregates are raw + * + * `sum`, `avg`, `min` and `max` read the amount (ADR-0144 Open item 10). + * `count` reads no amount, and `count_distinct` counts values without adding + * them, so neither is judged. Grouping by the code field makes every group one + * currency, which is the one shape the ruling admits; a consolidated total + * reads the stored home-currency field instead (D6), an ordinary currency + * field this door never judges. + * + * ## Where it stands + * + * The aggregate half stands in `ObjectQL.aggregate` beside the aggregate × + * field-type door (`aggregate-field-type-door.ts`), before any driver is + * asked, so it holds for every caller that reaches the engine — the REST + * query door, a flow, a hook, a roll-up's recompute and the analytics + * strategy. It answers that door's `INVALID_FIELD` / 400: the verdict is about + * the named field at a position. ADR-0144 assigns no new wire code to it. + * + * The roll-up half stands in `SchemaRegistry.registerObject`, the one door + * every metadata path passes, before anything is materialized, so a refused + * declaration leaves nothing behind. It answers `INVALID_METADATA` / 422, the + * registry's code for a programmatically supplied body it refuses. The pair is + * judged at whichever registration completes it: the parent's, when the child + * is registered already, or the child's, when a registered parent rolls up the + * field the child now declares `record`. A pair with an end not registered yet + * is no verdict: the other end's registration judges it. + */ + +import { StandardErrorCode } from '@objectstack/spec/api'; +import { recordCurrencyFieldOf } from './validation/record-currency-code.js'; + +/** The aggregate functions that read the amount — ADR-0144 Open item 10. */ +const RAW_AMOUNT_FUNCTIONS: Readonly> = { + sum: { does: 'sums' }, + avg: { does: 'averages' }, + min: { does: 'takes the min of' }, + max: { does: 'takes the max of' }, +}; + +/** Is `fn` an aggregate that reads the amount? */ +export function readsRecordCurrencyAmount(fn: unknown): boolean { + return typeof fn === 'string' && Object.prototype.hasOwnProperty.call(RAW_AMOUNT_FUNCTIONS, fn); +} + +/** The field names a `groupBy` groups on — its string entries and its `{ field }` entries. */ +function groupedFields(groupBy: unknown): Set { + const names = new Set(); + if (!Array.isArray(groupBy)) return names; + for (const entry of groupBy) { + if (typeof entry === 'string') names.add(entry); + else if (entry !== null && typeof entry === 'object' && typeof (entry as { field?: unknown }).field === 'string') { + names.add((entry as { field: string }).field); + } + } + return names; +} + +interface UngroupedAmount { + readonly fn: string; + readonly field: string; + readonly codeField: string; + readonly position: string; +} + +/** + * Refuse a raw aggregation over a declared `record`-mode amount when the query + * does not group by its code field — `INVALID_FIELD` / 400, before any driver + * is asked. See the module header. + * + * The route comes before the reason so it lands inside the 500 characters the + * REST door keeps of a 4xx message. + */ +export function assertRecordCurrencyAggregatesGrouped( + object: string, + schema: unknown, + groupBy: unknown, + aggregations: unknown, +): void { + if (!Array.isArray(aggregations) || aggregations.length === 0) return; + const fields = (schema as { fields?: unknown } | undefined)?.fields; + if (!fields || typeof fields !== 'object') return; + const grouped = groupedFields(groupBy); + const hits: UngroupedAmount[] = []; + for (const [i, agg] of aggregations.entries()) { + if (agg === null || typeof agg !== 'object' || Array.isArray(agg)) continue; + const fn = (agg as { function?: unknown }).function; + if (!readsRecordCurrencyAmount(fn)) continue; + const field = (agg as { field?: unknown }).field; + if (typeof field !== 'string' || !Object.prototype.hasOwnProperty.call(fields, field)) continue; + const codeField = recordCurrencyFieldOf((fields as Record)[field]); + if (codeField === undefined || grouped.has(codeField)) continue; + hits.push({ fn: fn as string, field, codeField, position: `aggregations[${i}].field` }); + } + if (hits.length === 0) return; + const [first] = hits; + const err = new Error( + `aggregate('${object}'): ${first.position} ${RAW_AMOUNT_FUNCTIONS[first.fn].does} '${first.field}', an amount ` + + `in each record's own currency (currencyMode: 'record', the code in '${first.codeField}'), and the query ` + + `does not group by '${first.codeField}'` + + (hits.length > 1 ? ` (also: ${hits.slice(1).map((h) => `'${h.field}'`).join(', ')})` : '') + + `. The query was NOT run. Add '${first.codeField}' to groupBy for one ${first.fn} per currency, or ` + + `aggregate a stored home-currency amount field for one consolidated figure. Amounts in different ` + + `currencies do not add: USD 100 and EUR 100 summed to 200 under one label.`, + ) as Error & { + code?: string; status?: number; httpStatus?: number; + field?: string; fields?: string[]; object?: string; param?: string; + }; + err.code = StandardErrorCode.enum.INVALID_FIELD; + err.status = 400; + err.httpStatus = 400; + err.field = first.field; + err.fields = hits.map((h) => h.field); + err.object = object; + err.param = 'aggregations'; + throw err; +} + +/** + * [ADR-0144 D6] The registry's code for this refusal: `INVALID_METADATA`, its + * spelling for a programmatically supplied body it refuses. ⛔ Not a new code; + * the condition is named by the error's `name`. + */ +export const RECORD_CURRENCY_SUMMARY_REFUSED_CODE = 'INVALID_METADATA' as const; + +/** + * A `summary` field rolls up `sum` / `avg` / `min` / `max` of a child field + * declared `currencyMode: 'record'`. The roll-up adds the child rows' amounts + * into one stored number with no currency. The ADR-0112 envelope (`code` + + * `status: 422`), never a bare throw. + */ +export class RecordCurrencySummaryRefusedError extends Error { + readonly code = RECORD_CURRENCY_SUMMARY_REFUSED_CODE; + readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads. `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 422; + /** The object declaring the `summary` field. */ + readonly objectName: string; + /** The `summary` field. */ + readonly fieldName: string; + /** The child object it rolls up. */ + readonly childObject: string; + /** The child's record-mode amount. */ + readonly childField: string; + + constructor(parent: string, summaryField: string, fn: string, child: string, childField: string, codeField: string) { + super( + `summary field "${parent}.${summaryField}" rolls up ${fn}(${child}.${childField}), and ${child}.${childField} ` + + `is an amount in each record's own currency (currencyMode: 'record', the code in '${codeField}'). A ` + + `roll-up stores one number with no currency, so child rows in different currencies would be added ` + + `together: USD 100 and JPY 15000 rolled up to 15100. Use function: 'count', or roll up a stored ` + + `home-currency amount field of '${child}' instead.`, + ); + this.name = 'RecordCurrencySummaryRefusedError'; + this.objectName = parent; + this.fieldName = summaryField; + this.childObject = child; + this.childField = childField; + } +} + +interface ObjectLike { + readonly name?: unknown; + readonly fields?: unknown; +} + +function fieldsOf(obj: ObjectLike | undefined): Record | undefined { + const f = obj?.fields; + return f && typeof f === 'object' && !Array.isArray(f) ? (f as Record) : undefined; +} + +/** A `summary` field's roll-up when it reads the amount: `{ fn, child, childField }`. */ +function rawRollupOf(def: unknown): { fn: string; child: string; childField: string } | undefined { + if (def === null || typeof def !== 'object' || (def as { type?: unknown }).type !== 'summary') return undefined; + const ops = (def as { summaryOperations?: unknown }).summaryOperations as + { object?: unknown; field?: unknown; function?: unknown } | undefined; + if (!ops || !readsRecordCurrencyAmount(ops.function)) return undefined; + if (typeof ops.object !== 'string' || typeof ops.field !== 'string') return undefined; + return { fn: ops.function as string, child: ops.object, childField: ops.field }; +} + +/** + * The refusal for an object about to register, or `undefined` — see the + * module header. `lookup` resolves a REGISTERED object by name and `all` + * lists the registered objects; both are asked only as far as the incoming + * body's own fields make necessary. + */ +export function recordCurrencySummaryRefusal( + incoming: ObjectLike, + lookup: (name: string) => ObjectLike | undefined, + all: () => readonly ObjectLike[], +): RecordCurrencySummaryRefusedError | undefined { + const name = typeof incoming.name === 'string' ? incoming.name : undefined; + const own = fieldsOf(incoming); + if (!name || !own) return undefined; + + // The incoming body's own roll-ups, over a child already registered (or over itself). + for (const [summaryField, def] of Object.entries(own)) { + const rollup = rawRollupOf(def); + if (!rollup) continue; + const childFields = rollup.child === name ? own : fieldsOf(lookup(rollup.child)); + const codeField = childFields ? recordCurrencyFieldOf(childFields[rollup.childField]) : undefined; + if (codeField !== undefined) { + return new RecordCurrencySummaryRefusedError(name, summaryField, rollup.fn, rollup.child, rollup.childField, codeField); + } + } + + // The incoming body's record-mode amounts, rolled up by a parent already registered. + const recordAmounts = new Map(); + for (const [field, def] of Object.entries(own)) { + const codeField = recordCurrencyFieldOf(def); + if (codeField !== undefined) recordAmounts.set(field, codeField); + } + if (recordAmounts.size === 0) return undefined; + for (const parent of all()) { + const parentName = typeof parent.name === 'string' ? parent.name : undefined; + if (!parentName || parentName === name) continue; + for (const [summaryField, def] of Object.entries(fieldsOf(parent) ?? {})) { + const rollup = rawRollupOf(def); + if (!rollup || rollup.child !== name) continue; + const codeField = recordAmounts.get(rollup.childField); + if (codeField !== undefined) { + return new RecordCurrencySummaryRefusedError(parentName, summaryField, rollup.fn, name, rollup.childField, codeField); + } + } + } + return undefined; +} diff --git a/packages/objectql/src/record-currency-guards.test.ts b/packages/objectql/src/record-currency-guards.test.ts new file mode 100644 index 00000000000..926ef929c30 --- /dev/null +++ b/packages/objectql/src/record-currency-guards.test.ts @@ -0,0 +1,336 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0144 D6, D8 rule 4] The three record-currency guards, through the engine +// and the registry: +// +// - a write storing a `record`-mode amount beside a blank code is refused +// `ERR_CURRENCY_CODE_REQUIRED` / 400 (insert, by-id update, predicate update); +// - `sum` / `avg` / `min` / `max` over a `record`-mode amount is refused +// `INVALID_FIELD` / 400 unless the query groups by the code field; +// - a `summary` roll-up reading one is refused `INVALID_METADATA` / 422 at +// registration, whichever end registers second. +// +// The fixture is the measurement round's `fx_invoice` (epic #8345, comment +// 6041378776), with the sibling code field DECLARED as the record currency. +// Every refusal here precedes the driver, so the driver is the rows-shaped +// in-memory double the roll-up suite uses; the same probes on SqlDriver over +// better-sqlite3 and on SqliteWasmDriver are packages/qa/dogfood's +// `test/record-currency-probes.test.ts`. The write's blank-code refusal is +// also the `validate()` preview's finding (changeset 26684's parity). + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { EngineAggregateOptions, EngineUpdateOptions } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k === '$or') return (v as any[]).some((w) => matches(row, w)); + if (v !== null && typeof v === 'object' && '$eq' in v) return row?.[k] === v.$eq; + if (v !== null && typeof v === 'object' && '$in' in v) return (v.$in as unknown[]).includes(row?.[k]); + return row?.[k] === v; + }); + }; + let n = 0; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + return page.map((r) => ({ ...r })); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(object: string, data: Record) { + n += 1; + const id = (data.id as string) ?? `r_${n}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const row = { ...s.get(id), ...data, id }; + s.set(id, row); + return row; + }, + async updateMany(object: string, ast: any, data: Record) { + const s = storeFor(object); + let count = 0; + for (const [id, r] of Array.from(s.entries())) { + if (matches(r, ast?.where)) { s.set(id, { ...r, ...data, id }); count += 1; } + } + return count; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async deleteMany() { return 0; }, + async count() { return 0; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +type Thrown = (Error & { + code?: string; status?: number; httpStatus?: number; field?: string; fields?: string[]; + amountField?: string; object?: string; param?: string; objectName?: string; fieldName?: string; +}) | null; + +const refusalOf = async (p: Promise | (() => unknown)): Promise => { + try { + await (typeof p === 'function' ? p() : p); + return null; + } catch (e) { + return e as Thrown; + } +}; +const envelopeOf = (err: Thrown) => ({ code: err?.code, status: err?.status, httpStatus: err?.httpStatus }); + +const CODE_REQUIRED = { code: 'ERR_CURRENCY_CODE_REQUIRED', status: 400, httpStatus: 400 }; +const INVALID_FIELD = { code: 'INVALID_FIELD', status: 400, httpStatus: 400 }; +const INVALID_METADATA = { code: 'INVALID_METADATA', status: 422, httpStatus: 422 }; + +const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const; +/** Typed at the call, never asserted (`check:query-options-erasure`). */ +const aggregateQuery = (query: EngineAggregateOptions): EngineAggregateOptions => query; +const BATCH: EngineUpdateOptions = { where: { title: 'batch' }, multi: true }; + +const FX_INVOICE = { + name: 'fx_invoice', + fields: { + title: { type: 'text' }, + currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, + amount: { type: 'currency', currencyConfig: RECORD }, + amt_usd: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }, + }, +}; + +async function bootEngine() { + const engine = new ObjectQL(); + const d = makeDriver(); + engine.registerDriver(d.driver, true); + await engine.init(); + return { engine, storeFor: d.storeFor }; +} + +describe('a record-mode amount is stored with its currency code (ERR_CURRENCY_CODE_REQUIRED)', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + + beforeEach(async () => { + ({ engine, storeFor } = await bootEngine()); + engine.registry.registerObject(FX_INVOICE as any); + }); + + it('insert: an amount beside a blank code is refused, naming the code field, and nothing is written', async () => { + for (const code of [undefined, null, '', ' ']) { + const err = await refusalOf(engine.insert('fx_invoice', { title: 'i1', amount: 100, ...(code === undefined ? {} : { currency_code: code }) })); + expect(envelopeOf(err), String(code)).toEqual(CODE_REQUIRED); + expect(err?.field).toBe('currency_code'); + expect(err?.fields).toEqual(['currency_code']); + expect(err?.amountField).toBe('amount'); + expect(err?.message).toContain("'currency_code' is blank"); + } + expect(storeFor('fx_invoice').size).toBe(0); + }); + + it('insert: an amount with its code, or no amount at all, is written', async () => { + await engine.insert('fx_invoice', { title: 'i1', amount: 100, currency_code: 'USD' }); + await engine.insert('fx_invoice', { title: 'i2', amount: 0, currency_code: 'JPY' }); + await engine.insert('fx_invoice', { title: 'i3' }); + await engine.insert('fx_invoice', { title: 'i4', amount: null }); + expect(storeFor('fx_invoice').size).toBe(4); + }); + + it('by-id update: clearing the code under a stored amount, or writing an amount onto a row with none, is refused', async () => { + const withCode = await engine.insert('fx_invoice', { title: 'i1', amount: 100, currency_code: 'USD' }); + const noAmount = await engine.insert('fx_invoice', { title: 'i2' }); + + const cleared = await refusalOf(engine.update('fx_invoice', { id: withCode.id, currency_code: null })); + expect(envelopeOf(cleared)).toEqual(CODE_REQUIRED); + expect(cleared?.message).toContain(`(record ${withCode.id})`); + expect(storeFor('fx_invoice').get(withCode.id).currency_code).toBe('USD'); + + const amountOnly = await refusalOf(engine.update('fx_invoice', { id: noAmount.id, amount: 50 })); + expect(envelopeOf(amountOnly)).toEqual(CODE_REQUIRED); + expect(storeFor('fx_invoice').get(noAmount.id).amount).toBeUndefined(); + + // The controls: the stored code covers a new amount; amount and code cleared together. + await engine.update('fx_invoice', { id: withCode.id, amount: 250 }); + expect(storeFor('fx_invoice').get(withCode.id).amount).toBe(250); + await engine.update('fx_invoice', { id: withCode.id, amount: null, currency_code: null }); + expect(storeFor('fx_invoice').get(withCode.id).currency_code).toBeNull(); + }); + + it('by-id update: a row stored before the guard is not refused for an edit that leaves the pair alone', async () => { + storeFor('fx_invoice').set('legacy', { id: 'legacy', title: 'old', amount: 100, currency_code: null }); + await engine.update('fx_invoice', { id: 'legacy', title: 'renamed' }); + expect(storeFor('fx_invoice').get('legacy').title).toBe('renamed'); + }); + + it('predicate update: one matched row left without a code refuses the whole write', async () => { + const a = await engine.insert('fx_invoice', { title: 'batch', amount: 1, currency_code: 'EUR' }); + const b = await engine.insert('fx_invoice', { title: 'batch' }); + + const err = await refusalOf(engine.update('fx_invoice', { amount: 9 }, BATCH)); + expect(envelopeOf(err)).toEqual(CODE_REQUIRED); + expect(err?.message).toContain(`(record ${b.id})`); + expect(storeFor('fx_invoice').get(a.id).amount).toBe(1); + + // The control: the same write carrying the code. + await engine.update('fx_invoice', { amount: 9, currency_code: 'EUR' }, BATCH); + expect(storeFor('fx_invoice').get(b.id)).toMatchObject({ amount: 9, currency_code: 'EUR' }); + }); + it('validate(): the insert and by-id update previews refuse what the write refuses, as the row\'s finding', async () => { + const insertPreview = await engine.validate('fx_invoice', [ + { title: 'p1', amount: 100 }, + { title: 'p2', amount: 100, currency_code: 'USD' }, + { title: 'p3' }, + ]); + expect(insertPreview.valid).toBe(false); + expect(insertPreview.results?.map((r) => r.valid)).toEqual([false, true, true]); + expect(insertPreview.results?.[0]?.errors).toEqual([ + { field: 'currency_code', code: 'ERR_CURRENCY_CODE_REQUIRED', message: expect.stringContaining("'currency_code' is blank") }, + ]); + + const withCode = await engine.insert('fx_invoice', { title: 'i1', amount: 100, currency_code: 'USD' }); + storeFor('fx_invoice').set('legacy', { id: 'legacy', title: 'old', amount: 100, currency_code: null }); + const cleared = await engine.validate('fx_invoice', { id: withCode.id, currency_code: null }, { mode: 'update' }); + expect(cleared.valid).toBe(false); + expect(cleared.results?.[0]?.errors).toEqual([ + { field: 'currency_code', code: 'ERR_CURRENCY_CODE_REQUIRED', message: expect.stringContaining(`(record ${withCode.id})`) }, + ]); + + // The controls, answered as the write answers them: the stored code covers + // a new amount, and a codeless legacy row's unrelated edit is not judged. + expect((await engine.validate('fx_invoice', { id: withCode.id, amount: 250 }, { mode: 'update' })).valid).toBe(true); + expect((await engine.validate('fx_invoice', { id: 'legacy', title: 'renamed' }, { mode: 'update' })).valid).toBe(true); + expect(storeFor('fx_invoice').get(withCode.id)).toMatchObject({ amount: 100, currency_code: 'USD' }); + }); +}); + +describe('a raw aggregate over a record-mode amount groups by its code field (PROBE-A, PROBE-C)', () => { + let engine: ObjectQL; + + beforeEach(async () => { + ({ engine } = await bootEngine()); + engine.registry.registerObject(FX_INVOICE as any); + await engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', amount: 100, amt_usd: 100 }); + await engine.insert('fx_invoice', { title: 'i2', currency_code: 'EUR', amount: 100, amt_usd: 0 }); + }); + + it('sum / avg / min / max ungrouped is refused INVALID_FIELD, with the route first', async () => { + for (const fn of ['sum', 'avg', 'min', 'max'] as const) { + const err = await refusalOf(engine.aggregate('fx_invoice', aggregateQuery({ + aggregations: [{ function: fn, field: 'amount', alias: 'v' }], + }))); + expect(envelopeOf(err), fn).toEqual(INVALID_FIELD); + expect(err?.field).toBe('amount'); + expect(err?.param).toBe('aggregations'); + expect(err?.message).toContain("does not group by 'currency_code'"); + expect(err?.message.indexOf("Add 'currency_code' to groupBy")).toBeLessThan(500); + } + }); + + it('grouped by a field other than the code is still refused', async () => { + const err = await refusalOf(engine.aggregate('fx_invoice', aggregateQuery({ + groupBy: ['title'], + aggregations: [{ function: 'sum', field: 'amount', alias: 'v' }], + }))); + expect(envelopeOf(err)).toEqual(INVALID_FIELD); + }); + + it('grouped by the code field answers one correct total per currency', async () => { + const groupings: Array = [['currency_code'], [{ field: 'currency_code' }]]; + for (const groupBy of groupings) { + const rows = await engine.aggregate('fx_invoice', aggregateQuery({ + groupBy, + aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }], + })); + const byCode = Object.fromEntries(rows.map((r: any) => [r.currency_code, r.total])); + expect(byCode).toEqual({ USD: 100, EUR: 100 }); + } + }); + + it('count, and a fixed-currency amount, are not judged', async () => { + const rows = await engine.aggregate('fx_invoice', aggregateQuery({ + aggregations: [ + { function: 'count', field: 'amount', alias: 'n' }, + { function: 'sum', field: 'amt_usd', alias: 'usd' }, + ], + })); + expect(rows[0]).toMatchObject({ n: 2, usd: 100 }); + }); +}); + +describe('a summary roll-up over a record-mode amount is refused at registration (PROBE-R)', () => { + const LINE = { + name: 'fx_line', + fields: { + currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, + amount: { type: 'currency', currencyConfig: RECORD }, + invoice: { type: 'master_detail', reference: 'fx_parent' }, + }, + }; + const parentWith = (fn: string, field = 'amount') => ({ + name: 'fx_parent', + fields: { + title: { type: 'text' }, + billed: { type: 'summary', summaryOperations: { object: 'fx_line', field, function: fn } }, + }, + }); + + it('the parent registering after the child is refused, naming the roll-up', async () => { + for (const fn of ['sum', 'avg', 'min', 'max']) { + const { engine } = await bootEngine(); + engine.registry.registerObject(LINE as any); + const err = await refusalOf(() => engine.registry.registerObject(parentWith(fn) as any)); + expect(envelopeOf(err), fn).toEqual(INVALID_METADATA); + expect(err?.name).toBe('RecordCurrencySummaryRefusedError'); + expect(err?.objectName).toBe('fx_parent'); + expect(err?.fieldName).toBe('billed'); + expect(err?.message).toContain(`rolls up ${fn}(fx_line.amount)`); + expect(engine.registry.getObject('fx_parent')).toBeUndefined(); + } + }); + + it('the child registering after the parent is refused, naming the same roll-up', async () => { + const { engine } = await bootEngine(); + engine.registry.registerObject(parentWith('sum') as any); + const err = await refusalOf(() => engine.registry.registerObject(LINE as any)); + expect(envelopeOf(err)).toEqual(INVALID_METADATA); + expect(err?.objectName).toBe('fx_parent'); + expect(err?.message).toContain('rolls up sum(fx_line.amount)'); + expect(engine.registry.getObject('fx_line')).toBeUndefined(); + }); + + it('a count roll-up over the amount, and a sum over a non-record child field, register', async () => { + const { engine } = await bootEngine(); + engine.registry.registerObject(LINE as any); + engine.registry.registerObject(parentWith('count') as any); + expect(engine.registry.getObject('fx_parent')).toBeDefined(); + + const other = await bootEngine(); + other.engine.registry.registerObject({ + ...LINE, + fields: { ...LINE.fields, qty: { type: 'number' } }, + } as any); + other.engine.registry.registerObject(parentWith('sum', 'qty') as any); + expect(other.engine.registry.getObject('fx_parent')).toBeDefined(); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 5c63cd3d85f..4c61b670569 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -103,6 +103,7 @@ import { type PicklistReference, } from './picklist-resolution.js'; import { standaloneActionOwnerKey, isObjectLessActionKey } from './action-governance.js'; +import { recordCurrencySummaryRefusal } from './record-currency-aggregate-door.js'; /** * [#24400] A bound top-level action whose target object no package has @@ -3622,6 +3623,17 @@ export class SchemaRegistry { if (ownership === 'extend' && (schema as { membershipKeys?: unknown }).membershipKeys !== undefined) { throw new ExtensionMembershipKeysRefusedError(schema.name, packageId); } + // [ADR-0144 D6] A `summary` roll-up that reads a `record`-mode amount — + // `sum` / `avg` / `min` / `max` of a child field whose rows each carry + // their own currency — is refused here, the door every metadata path + // passes, before any materialization: the pair is judged at whichever + // registration completes it. See `record-currency-aggregate-door.ts`. + const recordCurrencySummary = recordCurrencySummaryRefusal( + schema, + (name) => this.getObject(name), + () => this.getAllObjects(), + ); + if (recordCurrencySummary) throw recordCurrencySummary; // [#25744] A code package's object keeps its own actions as their // `ActionSchema` parse — `requiresFeature` / `requiresMembershipReach` diff --git a/packages/objectql/src/validation/record-currency-code.ts b/packages/objectql/src/validation/record-currency-code.ts new file mode 100644 index 00000000000..e1f62d31511 --- /dev/null +++ b/packages/objectql/src/validation/record-currency-code.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D2, D8 rule 4] A `record`-mode amount is stored with its currency + * code, or the write is refused. + * + * A `currency` field declaring `currencyConfig: { currencyMode: 'record', + * currencyField }` holds a bare number (ADR-0104 D1) whose currency is the + * ISO 4217 code in the sibling field `currencyField` names. An amount stored + * beside a blank code is a number in no currency: every reader would label it + * with something it never was. The ADR's design rule 4 settles who supplies + * the code: a form pre-fills the organization's currency at the renderer, and + * the stored value is always the one submitted — the server never fills one + * in. This refusal is what makes that true. + * + * ## What is judged + * + * The row's IMAGE after the write — the insert row as the driver will store + * it, or the stored row with the update payload laid over it. A pair is + * judged when its amount holds a value and its code field is blank (absent, + * `null`, or a string with no characters but white space). A blank amount + * needs no code; a code that is not an ISO 4217 member is the code field's + * own `valueDomain` refusal, judged by the record validator, not here. + * + * On an update only the pairs the payload touches — it carries the amount or + * the code — are judged, as every other write-time rule here judges only what + * a write changes: a row stored before the amount was declared `record` is + * not refused for an edit of an unrelated field. + * + * ## The answer + * + * `ERR_CURRENCY_CODE_REQUIRED` / 400, registered under `@objectstack/objectql` + * in the ADR-0112 ledger (ADR-0144 Open item 9), naming the code field in + * `field` and the amount in `amountField`. A top-level code rather than a + * field-level `VALIDATION_FAILED` entry because the ADR assigns this refusal + * to ADR-0112's vocabulary, and a client that pre-fills the code recognises it + * by that name. + */ + +/** The ADR-0112 `code` {@link RecordCurrencyCodeRequiredError} carries. */ +export const CURRENCY_CODE_REQUIRED_CODE = 'ERR_CURRENCY_CODE_REQUIRED' as const; + +/** One record-mode amount and the field that holds its currency code. */ +export interface RecordCurrencyPair { + /** The `currency` field declaring `currencyMode: 'record'`. */ + readonly amountField: string; + /** Its `currencyConfig.currencyField` — the sibling ISO 4217 code field. */ + readonly codeField: string; +} + +/** + * The code field a declared field's amount is in, when the field is a + * `record`-mode `currency` field naming one — `undefined` otherwise. Read off + * the declaration as registered: the parse already refused a `record` config + * with no pointer, and a body that skipped the parse and carries none names no + * currency this seam could judge. + */ +export function recordCurrencyFieldOf(def: unknown): string | undefined { + if (def === null || typeof def !== 'object') return undefined; + const { type, currencyConfig } = def as { type?: unknown; currencyConfig?: unknown }; + if (type !== 'currency' || currencyConfig === null || typeof currencyConfig !== 'object') return undefined; + const { currencyMode, currencyField } = currencyConfig as { currencyMode?: unknown; currencyField?: unknown }; + return currencyMode === 'record' && typeof currencyField === 'string' && currencyField !== '' + ? currencyField + : undefined; +} + +/** Every record-mode amount an object's field map declares, in declaration order. */ +export function recordCurrencyPairs(fields: unknown): RecordCurrencyPair[] { + if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) return []; + const pairs: RecordCurrencyPair[] = []; + for (const [amountField, def] of Object.entries(fields as Record)) { + const codeField = recordCurrencyFieldOf(def); + if (codeField !== undefined) pairs.push({ amountField, codeField }); + } + return pairs; +} + +function isBlank(value: unknown): boolean { + return value === undefined || value === null || (typeof value === 'string' && value.trim() === ''); +} + +/** + * `ERR_CURRENCY_CODE_REQUIRED` / 400 — see the module header. Identified by + * `code`, which survives crossing package boundaries. + */ +export class RecordCurrencyCodeRequiredError extends Error { + readonly code = CURRENCY_CODE_REQUIRED_CODE; + readonly status = 400; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads. `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 400; + /** The object written. */ + readonly object: string; + /** The first blank code field — the one the caller must fill. */ + readonly field: string; + /** Every blank code field of the row, first included. */ + readonly fields: string[]; + /** The record-mode amount whose currency `field` holds. */ + readonly amountField: string; + + constructor(object: string, operation: 'insert' | 'update', hits: readonly RecordCurrencyPair[], recordId?: unknown) { + const [first] = hits; + const row = recordId === undefined || recordId === null ? '' : ` (record ${String(recordId)})`; + super( + `${operation === 'insert' ? 'Insert' : 'Update'} on '${object}'${row} was REFUSED: '${first.amountField}' ` + + `holds an amount in the record's own currency (currencyMode: 'record'), and its currency code field ` + + `'${first.codeField}' is blank` + + (hits.length > 1 ? ` (also: ${hits.slice(1).map((h) => `'${h.codeField}' for '${h.amountField}'`).join(', ')})` : '') + + `. The row was not written. Write the record's ISO 4217 code (for example 'USD') into '${first.codeField}' ` + + `in the same write, or leave '${first.amountField}' empty. The server never fills a currency in: a form ` + + `pre-fills the organization's currency, and the stored code is always the one submitted.`, + ); + this.name = 'RecordCurrencyCodeRequiredError'; + this.object = object; + this.field = first.codeField; + this.fields = [...new Set(hits.map((h) => h.codeField))]; + this.amountField = first.amountField; + } +} + +/** + * Refuse a row image whose record-mode amount holds a value beside a blank + * currency code — see the module header. + * + * @param image the row as it will be stored (insert row, or stored row ⊕ payload) + * @param touched an update's payload: only the pairs it carries a key of are judged; + * omitted on insert, where every pair is judged + */ +export function assertRecordCurrencyCodes( + object: string, + fields: unknown, + image: Record | null | undefined, + operation: 'insert' | 'update', + touched?: Record | null, + recordId?: unknown, +): void { + if (!image) return; + const pairs = recordCurrencyPairs(fields); + if (pairs.length === 0) return; + const has = (key: string) => touched != null && Object.prototype.hasOwnProperty.call(touched, key); + const hits = pairs.filter((p) => { + if (operation === 'update' && !has(p.amountField) && !has(p.codeField)) return false; + return !isBlank(image[p.amountField]) && isBlank(image[p.codeField]); + }); + if (hits.length > 0) throw new RecordCurrencyCodeRequiredError(object, operation, hits, recordId); +} + +/** + * Does an update payload touch any record-mode pair? The engine asks it before + * paying for the stored rows a predicate update would otherwise not read. + */ +export function touchesRecordCurrency(fields: unknown, payload: Record | null | undefined): boolean { + if (!payload) return false; + return recordCurrencyPairs(fields).some( + (p) => Object.prototype.hasOwnProperty.call(payload, p.amountField) + || Object.prototype.hasOwnProperty.call(payload, p.codeField), + ); +} diff --git a/packages/qa/dogfood/test/record-currency-probes.test.ts b/packages/qa/dogfood/test/record-currency-probes.test.ts new file mode 100644 index 00000000000..7c619033dd8 --- /dev/null +++ b/packages/qa/dogfood/test/record-currency-probes.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0144 D6, D8 rule 4] The measurement round's probes (epic #8345, comment +// 6041378776) as fixtures, on the real SQLite drivers, with the record currency +// DECLARED (`currencyMode: 'record'`, the code in `currency_code`): +// +// - PROBE-A: `sum` over a USD row of 100 and an EUR row of 100 answered `200` +// under one label. Now refused `INVALID_FIELD` / 400 unless grouped by the +// code field; grouped (PROBE-C), each currency's total comes back on its own. +// - PROBE-R: a `summary` roll-up over child rows of USD 100 and JPY 15000 +// stored `15100`. Now the roll-up is refused `INVALID_METADATA` / 422 when +// the app registers. +// - The write side: an amount with no code is refused +// `ERR_CURRENCY_CODE_REQUIRED` / 400, and the table stays empty. +// +// The engine-door cells over the in-memory aggregation face live beside the +// guards in `packages/objectql/src/record-currency-guards.test.ts`. ⛔ No +// driver-memory arm: its test consumers are a closed census +// (`check:driver-memory-census`), and the project's test backends are sqlite +// `:memory:`. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import type { EngineAggregateOptions } from '@objectstack/spec/data'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const DRIVERS = [ + { + name: 'driver-sql (better-sqlite3 :memory:)', + make: (): SqlDriver => + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + }, + { + name: 'driver-sqlite-wasm (:memory:)', + make: (): SqlDriver => new SqliteWasmDriver({ filename: ':memory:' }), + }, +]; + +/** Typed at the call, never asserted (`check:query-options-erasure`). */ +const aggregateQuery = (query: EngineAggregateOptions): EngineAggregateOptions => query; + +const SYS = { context: { isSystem: true, userId: 'usr_system' } }; + +const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const; +const CODE = { name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' }; + +const FX_INVOICE = { + name: 'fx_invoice', + label: 'FX invoice', + fields: { + name: { name: 'name', type: 'text' }, + currency_code: CODE, + amount: { name: 'amount', type: 'currency', currencyConfig: RECORD }, + }, +}; + +const appWith = (objects: unknown[]) => ({ + id: 'com.objectstack.dogfood.record-currency-probes', + name: 'Record currency probes', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects, +}); + +interface Refusal { code?: unknown; status?: unknown; field?: unknown; name?: unknown; message?: unknown } +const envelope = (e: Refusal) => ({ code: e.code, status: e.status, field: e.field }); + +async function refusalOf(run: () => unknown): Promise { + try { + await run(); + } catch (e) { + return e as Refusal; + } + throw new Error('expected the engine to refuse, and it answered'); +} + +describe.each(DRIVERS)('record-currency probes: $name', ({ make }) => { + let driver: SqlDriver; + let engine: ObjectQL; + + beforeEach(async () => { + driver = make(); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + }); + + afterEach(async () => { + await engine.destroy(); + }); + + async function boot(objects: unknown[]) { + engine.registerApp(appWith(objects)); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + } + + it('PROBE-A: an ungrouped sum over USD 100 + EUR 100 is refused; grouped by the code, each currency totals alone', async () => { + await boot([FX_INVOICE]); + await engine.insert('fx_invoice', { name: 'i1', currency_code: 'USD', amount: 100 }, SYS); + await engine.insert('fx_invoice', { name: 'i2', currency_code: 'EUR', amount: 100 }, SYS); + + for (const fn of ['sum', 'avg', 'min', 'max'] as const) { + const err = await refusalOf(() => engine.aggregate('fx_invoice', aggregateQuery({ + aggregations: [{ function: fn, field: 'amount', alias: 'v' }], + }), SYS)); + expect(envelope(err), fn).toEqual({ code: 'INVALID_FIELD', status: 400, field: 'amount' }); + } + + const rows = await engine.aggregate('fx_invoice', aggregateQuery({ + groupBy: ['currency_code'], + aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }], + }), SYS); + const byCode = Object.fromEntries((rows as Array>).map((r) => [r.currency_code, Number(r.total)])); + expect(byCode).toEqual({ USD: 100, EUR: 100 }); + }); + + it('the write side: an amount with no code is refused and nothing is stored', async () => { + await boot([FX_INVOICE]); + const err = await refusalOf(() => engine.insert('fx_invoice', { name: 'i1', amount: 100 }, SYS)); + expect(envelope(err)).toEqual({ code: 'ERR_CURRENCY_CODE_REQUIRED', status: 400, field: 'currency_code' }); + expect(await engine.find('fx_invoice', {}, SYS)).toEqual([]); + }); + + it('PROBE-R: a summary roll-up over the record-mode amount is refused when the app registers', async () => { + const parent = { + name: 'fx_bill', + label: 'FX bill', + fields: { + name: { name: 'name', type: 'text' }, + billed_total: { name: 'billed_total', type: 'summary', summaryOperations: { object: 'fx_bill_line', field: 'amount', function: 'sum' } }, + }, + }; + const line = { + name: 'fx_bill_line', + label: 'FX bill line', + fields: { + currency_code: CODE, + amount: { name: 'amount', type: 'currency', currencyConfig: RECORD }, + bill: { name: 'bill', type: 'master_detail', reference: 'fx_bill' }, + }, + }; + const err = await refusalOf(() => engine.registerApp(appWith([parent, line]))); + expect({ code: err.code, status: err.status, name: err.name }).toEqual({ + code: 'INVALID_METADATA', status: 422, name: 'RecordCurrencySummaryRefusedError', + }); + expect(String(err.message)).toContain('rolls up sum(fx_bill_line.amount)'); + }); +}); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 87fc732a334..3b6ea4f799b 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -784,6 +784,12 @@ export const ERROR_CODE_LEDGER = { // the one the transaction was opened on (`CrossDatasourceTransactionWriteError`, // `transaction-errors.ts`). Same #8087-gate family as above. 'ERR_CROSS_DATASOURCE_TRANSACTION_WRITE', + // [ADR-0144 D8 rule 4, Open item 9] A write would store a `record`-mode + // currency amount beside a blank currency code — a number in no currency. + // 400; the driver is never asked to write the row (`RecordCurrencyCodeRequiredError`, + // `validation/record-currency-code.ts`); `field` names the code field to + // fill. The server never fills a currency in. + 'ERR_CURRENCY_CODE_REQUIRED', 'ERR_DATASOURCE_UNAVAILABLE', 'ERR_DRIVER_CONNECT', // [#8846] a `before*` hook moved or cleared the id the engine resolved a