Skip to content

Commit 57c51aa

Browse files
feat(objectql): record-currency guards — blank code refuses the write; ungrouped sum/avg/min/max over a record-mode amount refused; summary roll-up over one refused at registration (#27339)
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5 <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg9oTskgBVZmtxXLAEh251Cb)_ Fixes #27186 ⛔ not released alone — R1: pairs with #27184, #27186, #27187, #27188 (one Version Packages release) Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么:** record 模式金额写入时币种码为空 ⇒ 拒写(`ERR_CURRENCY_CODE_REQUIRED`,`validate()` 预览同样拒);`sum/avg/min/max` 对 record 金额不按币种码分组 ⇒ 拒收;summary 汇总 record 子字段 ⇒ 注册时拒收;公式引用 record 金额时声明为 record 币种。 - **为什么改:** 测量轮探针里 USD 100 + EUR 100 = 200、USD 100 + JPY 15000 汇总成 15100,都是静默错数;这三道门把它们变成响亮拒收(ADR-0144 D6、D8 规则 4)。 - **风险与回滚:** 只对声明了 `currencyMode: 'record'` 的字段生效,该模式由已合并的 #27184 引入,其他字段零影响;回滚即撤本 PR,但须与 R1 同批。 - **先看:** `packages/objectql/src/validation/record-currency-code.ts`(更新路径用存量行叠加载荷判定);`packages/objectql/src/record-currency-aggregate-door.ts`;`packages/objectql/src/registry.ts` 的注册拒收位置。 - **两分钟验证:** `pnpm --filter @objectstack/objectql exec vitest run --project local src/record-currency-guards.test.ts`,预期 13/13 通过(混币 sum 被拒、按币种码分组得到 USD 100 / EUR 100)。 - **需要你做的:** 无,席位落地(与 R1 同批发版)。 ## What changes - **Blank code refuses the write (ADR-0144 D8 rule 4, Open item 9).** `validation/record-currency-code.ts` refuses a row image that holds a value in a `record`-mode amount beside a blank code field (absent, `null`, white space) with `ERR_CURRENCY_CODE_REQUIRED` / 400; `field` names the code field, `amountField` the amount. Judged on insert (each final row), by-id update (stored row with the final payload over it) and predicate update (each matched row with the payload over it, read only when the payload carries an amount or its code). An update that does not touch the pair is never judged, so a pre-existing row is not refused for an unrelated edit. The `validate()` preview (`engine-validate.ts`, `validateData`) runs the same check at the write's step, on the same image (the insert row; the stored row with the patch over it for a by-id update), and answers it as the row's `ERR_CURRENCY_CODE_REQUIRED` finding, so a dry run does not pass a row the write refuses (changeset 26684's parity). A predicate-dispatched update preview holds no stored row and is not judged. The code is registered under `@objectstack/objectql` in the ADR-0112 ledger (`error-code-ledger.zod.ts`); the generated reference pages are regenerated. - **Ungrouped raw aggregate refused (ADR-0144 D6, Open item 10).** `record-currency-aggregate-door.ts`, called in `ObjectQL.aggregate` right after the aggregate × field-type door: `sum` / `avg` / `min` / `max` over a `record`-mode amount answers `INVALID_FIELD` / 400 unless `groupBy` names its code field (string or `{ field }` entry). The message leads with the prescription (group by the code field, or aggregate a stored home-currency amount). `count` and `count_distinct` are not judged. No new wire code: ADR-0144 assigns ADR-0112 codes only to the blank-code and missing-rate refusals. - **Summary roll-up refused at registration (ADR-0144 D6).** `SchemaRegistry.registerObject` refuses, before any materialization, a body whose `summary` field rolls up `sum` / `avg` / `min` / `max` of a `record`-mode child field — or a child body declaring a `record`-mode field that a registered parent already rolls up that way. `INVALID_METADATA` / 422 (`RecordCurrencySummaryRefusedError`), judged at whichever registration completes the pair; an end not yet registered is no verdict. - **Rider, declared — from #27184's class-c finding.** `packages/formula/src/formula-return.ts`: `currencyOf` mapped every non-`fixed` config to `dynamic`, so `inferFormulaReturn` would stamp a formula over a record-mode amount as a tenant-default amount. It now carries `currencyConfig: { currencyMode: 'record', currencyField }` (the shape #27184's object refinement already judges on a `formula`). Two record-mode amounts are one currency only when they name the same code field; record vs `fixed` / `dynamic` is not one currency. - **File-surface addition, approved by the epic PM:** `packages/qa/dogfood/test/record-currency-probes.test.ts` (test only, private package, no changeset). ## Tests - `packages/objectql/src/record-currency-guards.test.ts` (new, 13 cases): the blank-code refusal on insert (four blank spellings), by-id update (clear code / amount onto a row without one / legacy row untouched), predicate update (one matched row without a code refuses the whole write, naming the record); the `validate()` preview, insert and by-id update, answers the same finding, with the stored-code and legacy-row controls valid; the aggregate door for all four functions, grouped-by-other refused, grouped by the code answers USD 100 / EUR 100 (string and object `groupBy`), `count` and a fixed-currency sum not judged; the summary refusal parent-second (all four functions) and child-second, `count` and a non-record child field register. Every thrown refusal asserts `code` + `status` + `httpStatus`; the preview's finding asserts `field` + `code` + message. - `packages/qa/dogfood/test/record-currency-probes.test.ts` (new): PROBE-A, the write side and PROBE-R on `SqlDriver` over better-sqlite3 and on `SqliteWasmDriver`, 6/6. ⛔ No driver-memory arm: its test consumers are a closed census (`check:driver-memory-census`) and the project's test backends are sqlite `:memory:`; the in-memory aggregation face is the objectql suite above. - `packages/formula/src/formula-return.test.ts`: a new describe for the record currency (stamped with its code field; ratio of two same-currency amounts is not money; three cross-currency mixes are not money). ## Acceptance notes - `Clause-②: yes (widening)`: the ledger gains a registered code (the ledger header's #16404 ruling), and the formula rider widens the exported `FormulaReturnDeclaration` by one union member. The level stays `patch` and `Metadata-protocol: none` (precedent `26506-toggle-flow-caller.md`). - The update preview judges the stored row as the read door served it to the caller. A code column withheld from that caller is absent from that row, so the preview reads it as blank and refuses where the write would admit: stricter than the write, never laxer, and it reveals nothing about the withheld value. - PROBE-A / PROBE-R come from the measurement round's design comment on #8345 (6041378776); the probe files themselves were deleted then, so these are re-authored fixtures with the record currency declared, not the originals. - A row that was stored with an amount and no code before this guard is not refused until a write touches the amount or the code; ADR-0144 has no backfill for that state, and `record` mode ships in the same release, so no such row exists in a released build. ## Verification Code readings on `2749517c`, the last commit that changes this PR's code. Formula, spec and platform-objects readings are on `cf5be508`; nothing after it touches those packages' code (the one spec change is a ledger comment). On the head, `9d9f7eab` (main `f7188b56` merged in with a merge commit; no regeneration was needed), the guards, the probes, `check:generated` and the derived gates were re-read; see each section. <details><summary>Tests</summary> - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 src/record-currency-guards.test.ts`: Test Files 1 passed, Tests 13 passed (13), on `2749517c` and again on `9d9f7eab`. - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/record-currency-probes.test.ts`: Test Files 1 passed, Tests 6 passed (6), on both SQLite drivers, on `2749517c` and again on `9d9f7eab`. - Full `@objectstack/objectql` local project: Test Files 479 passed (479), Tests 8951 passed (8951). - Full `@objectstack/formula`: Test Files 60 passed (60), Tests 1563 passed (1563). - `@objectstack/spec` `src/data/currency-config-record-mode.test.ts` (#27184's refinement, which the guards read): 15 passed. Full `@objectstack/platform-objects`: 1369 passed (97 files). - `pnpm --filter @objectstack/objectql typecheck` (tsc + check:test-typecheck): exit 0. `@objectstack/formula` typecheck at `cf5be508`: exit 0. - The dogfood package typecheck was not measured, because its closure is unbuilt in this container. No error line names the new file. </details> <details><summary>Ablation (one-shot; suite imports src, no build leg)</summary> `scripts/ablation-replace.mjs --delete` on the committed tree, run against `record-currency-guards.test.ts`. Each time the restore was verified (blob == HEAD) and `git diff HEAD` was empty. - The `assertRecordCurrencyAggregatesGrouped` call in `engine.ts`: 2 failed / 10 passed (read at `cf5be508`, 12 cases). - The insert `assertRecordCurrencyCodes` call in `engine.ts`: 1 failed / 11 passed (read at `cf5be508`, 12 cases). - The `throw recordCurrencySummary` in `registry.ts`: 2 failed / 10 passed (read at `cf5be508`, 12 cases). - The preview's guard in `engine-validate.ts` (the `if (mode === 'insert' || storedRows[i])` arm made false): 1 failed / 12 passed, at `2749517c`. The by-id and predicate update call sites were not ablated separately. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `9d9f7eab`, change set = this PR's 14 paths against merge base `f7188b56`: 111 families derived, all 111 run. The `--ran` reconciliation reads 109 run, 2 NOT-MEASURED, 0 UNRUN. - NOT MEASURED (exit 3, prerequisite not met in this container): `check-plugin-teardown-shape --self-test` (shallow clone) and spec `check:skill-examples` (the client SDK dist predates the merged main). CI runs both and is green. - Red on the first sweep and fixed in this PR: - `check:docs` / `check:generated`: the reference pages lacked the new ledger code, so they were regenerated. - `check:objectql-double-limit` and `check:query-options-erasure`: the new test's driver double and option casts were fixed. - `pnpm --filter @objectstack/spec check:generated` on `9d9f7eab`, against a spec dist built by that run: all 14 artifacts current. - CI on `9d9f7eab`: 50 check runs, 41 success, 9 skipped, 0 failure. Check Changeset, Test Core (all 8 shards) and every Type Check and Lint job pass. GitHub reports the PR `clean`. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3604371 commit 57c51aa

14 files changed

Lines changed: 1062 additions & 15 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.

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ const result = AutomationApiErrorCode.parse(data);
347347

348348
| Property | Type | Required | Description |
349349
| :--- | :--- | :--- | :--- |
350-
| **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 |
350+
| **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 |
351351
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
352352

353353
### Allowed Values: `FlowInnerRefusal.code`
@@ -479,6 +479,7 @@ const result = AutomationApiErrorCode.parse(data);
479479
* `ERR_AUTONUMBER_COLLISION`
480480
* `ERR_BULK_RESULT_MISMATCH`
481481
* `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE`
482+
* `ERR_CURRENCY_CODE_REQUIRED`
482483
* `ERR_DATASOURCE_UNAVAILABLE`
483484
* `ERR_DRIVER_CONNECT`
484485
* `ERR_FILE_CONSTRAINT`

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **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) |
31+
| **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) |
3232
| **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) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **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);
167167
* `ERR_AUTONUMBER_COLLISION`
168168
* `ERR_BULK_RESULT_MISMATCH`
169169
* `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE`
170+
* `ERR_CURRENCY_CODE_REQUIRED`
170171
* `ERR_DATASOURCE_UNAVAILABLE`
171172
* `ERR_DRIVER_CONNECT`
172173
* `ERR_FILE_CONSTRAINT`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ const result = ErrorCode.parse(data);
334334
* `ERR_AUTONUMBER_COLLISION`
335335
* `ERR_BULK_RESULT_MISMATCH`
336336
* `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE`
337+
* `ERR_CURRENCY_CODE_REQUIRED`
337338
* `ERR_DATASOURCE_UNAVAILABLE`
338339
* `ERR_DRIVER_CONNECT`
339340
* `ERR_FILE_CONSTRAINT`

‎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)