From 3a39df54527302b2ee326cade84385f4c156e75a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:34:27 +0000 Subject: [PATCH 01/18] feat(spec): `currencyConfig.homeAmount: { field, rateDate, rateField }` on a record-mode amount A `record`-mode amount declares, by three required pointers into the same object, where the engine stores it converted into the home currency, which document date picks the rate, and where the rate used is stored (ADR-0144 D5). - `CurrencyConfigSchema` gains `homeAmount`, a closed shape whose three pointers are required and non-empty; it is refused outside `record`. - `ObjectSchema`'s superRefine gains `refuseDishonestHomeAmount`: the amount is a stored `currency` field; `field` names another `dynamic`-mode `currency` field no other amount stores into; `rateDate` a `date` / `datetime` field; `rateField` a `number` field, shared only by amounts with the same code field and document date. The home and rate fields declare `readonly: true` and no `expression`. Each refusal is located at its pointer and carries a remedy and the object's candidates. - ADR-0144 anchors on field.zod.ts and object.zod.ts gain the D5 invariant. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../data/currency-config-home-amount.test.ts | 248 ++++++++++++++++++ packages/spec/src/data/field.zod.ts | 78 +++++- packages/spec/src/data/object.zod.ts | 203 ++++++++++++++ ...ckages__spec__src__data__field.zod.ts.json | 2 +- ...kages__spec__src__data__object.zod.ts.json | 2 +- 5 files changed, 528 insertions(+), 5 deletions(-) create mode 100644 packages/spec/src/data/currency-config-home-amount.test.ts diff --git a/packages/spec/src/data/currency-config-home-amount.test.ts b/packages/spec/src/data/currency-config-home-amount.test.ts new file mode 100644 index 00000000000..042df547568 --- /dev/null +++ b/packages/spec/src/data/currency-config-home-amount.test.ts @@ -0,0 +1,248 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D5] `currencyConfig.homeAmount: { field, rateDate, rateField }` — + * a `record`-mode amount declares, by pointer, where the engine stores it + * converted into the home currency, which date picks the rate, and where the + * rate used is stored. + * + * Two doors, as for `currencyField`: `CurrencyConfigSchema` refuses what one + * config can see (`homeAmount` outside `record`, a missing, empty or unknown + * pointer), and the object's `superRefine` refuses what the pointers name. + * The home and rate fields are platform-written, marked with the existing + * `readonly: true`. + */ + +import { describe, it, expect } from 'vitest'; + +import { CurrencyConfigSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; + +const HOME = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' } as const; +const RECORD = { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME } as const; + +/** The fields every accepted invoice carries; a case overrides one of them. */ +const BASE_FIELDS = { + currency_code: { type: 'text', label: 'Currency', valueDomain: 'iso_4217_currency' }, + invoice_date: { type: 'date', label: 'Invoice date' }, + amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + amount_home: { type: 'currency', label: 'Amount (home)', readonly: true }, + amount_rate: { type: 'number', label: 'Rate used', readonly: true }, +} as const; + +function invoice(overrides: Record = {}): Record { + const fields: Record = { ...BASE_FIELDS, ...overrides }; + for (const [k, v] of Object.entries(fields)) if (v === undefined) delete fields[k]; + return { name: 'invoice', label: 'Invoice', fields }; +} + +/** The issues of a refused object parse, reduced to what an author is shown. */ +function objectIssues(input: unknown): Array<{ code: string; path: string; message: string }> { + const r = ObjectSchema.safeParse(input); + if (r.success) return []; + return r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); +} + +const AT = 'fields.amount.currencyConfig.homeAmount'; + +describe('`CurrencyConfigSchema`: `homeAmount` under `record` only, three required pointers', () => { + it('accepts a `record` config with all three pointers, keeping them in the parse output', () => { + const r = CurrencyConfigSchema.safeParse(RECORD); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data.homeAmount).toEqual(HOME); + expect(CurrencyConfigSchema.parse(r.data)).toEqual(r.data); + }); + + it('a `record` config without `homeAmount` parses as before, with no key added', () => { + const r = CurrencyConfigSchema.parse({ currencyMode: 'record', currencyField: 'currency_code' }); + expect(JSON.stringify(r)).toBe('{"currencyMode":"record","currencyField":"currency_code"}'); + }); + + it.each([ + ['fixed', { currencyMode: 'fixed', defaultCurrency: 'USD' }], + ['dynamic', { currencyMode: 'dynamic' }], + ['dynamic (defaulted)', {}], + ] as const)('refuses `homeAmount` under `%s`, at the key, with the remedy', (_mode, config) => { + const r = CurrencyConfigSchema.safeParse({ ...config, homeAmount: HOME }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.code).toBe('custom'); + expect(r.error.issues[0]!.path).toEqual(['homeAmount']); + expect(r.error.issues[0]!.message).toMatch(/^`homeAmount` is read only under `currencyMode: 'record'`/); + }); + + it.each(['field', 'rateDate', 'rateField'] as const)('refuses a `homeAmount` without `%s` (every pointer is required)', (key) => { + const { [key]: _dropped, ...rest } = HOME; + const r = CurrencyConfigSchema.safeParse({ ...RECORD, homeAmount: rest }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => i.path.join('.'))).toEqual([`homeAmount.${key}`]); + expect(r.error.issues[0]!.code).toBe('invalid_type'); + expect(r.error.issues[0]!.message).toMatch(new RegExp(`^\`homeAmount\` needs \`${key}\`, the name of `)); + }); + + it.each(['field', 'rateDate', 'rateField'] as const)('refuses an empty `homeAmount.%s`, at the pointer', (key) => { + const r = CurrencyConfigSchema.safeParse({ ...RECORD, homeAmount: { ...HOME, [key]: '' } }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => i.path.join('.'))).toEqual([`homeAmount.${key}`]); + expect(r.error.issues[0]!.message).toMatch(new RegExp(`^\`homeAmount\\.${key}\` names a field: an empty name points at nothing`)); + }); + + it('refuses an unknown key inside `homeAmount`, steering a near-miss to the declared pointer', () => { + const r = CurrencyConfigSchema.safeParse({ ...RECORD, homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rate: 'amount_rate' } }); + expect(r.success).toBe(false); + if (r.success) return; + const paths = r.error.issues.map((i) => i.path.join('.')); + expect(paths).toContain('homeAmount.rateField'); + const unknown = r.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(unknown?.message).toContain('rateField'); + }); +}); + +describe('the object judges what `homeAmount` names', () => { + it('accepts the honest shape: a readonly `dynamic` home field, a date, a readonly number rate field', () => { + expect(objectIssues(invoice())).toEqual([]); + // A `datetime` document date and a home field with an explicit `dynamic` config are honest too. + expect(objectIssues(invoice({ + invoice_date: { type: 'datetime', label: 'Issued at' }, + amount_home: { type: 'currency', label: 'Amount (home)', readonly: true, currencyConfig: { currencyMode: 'dynamic' } }, + }))).toEqual([]); + const once = ObjectSchema.parse(invoice()); + expect(ObjectSchema.parse(once)).toEqual(once); + }); + + it('two amounts with the same code field and document date may share one rate field', () => { + expect(objectIssues(invoice({ + tax: { type: 'currency', label: 'Tax', currencyConfig: { ...RECORD, homeAmount: { ...HOME, field: 'tax_home' } } }, + tax_home: { type: 'currency', label: 'Tax (home)', readonly: true }, + }))).toEqual([]); + }); + + describe('`homeAmount.field`', () => { + it('refuses a pointer into nothing, naming the candidates', () => { + const issues = objectIssues(invoice({ + amount: { type: 'currency', label: 'Amount', currencyConfig: { ...RECORD, homeAmount: { ...HOME, field: 'home' } } }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toBe(`${AT}.field`); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount\.field` names `home`, but object `invoice` declares no other field `home`/); + expect(issues[0]!.message).toContain('candidates: `amount_home`'); + }); + + it('refuses a pointer at the amount itself', () => { + const issues = objectIssues(invoice({ + amount: { type: 'currency', label: 'Amount', currencyConfig: { ...RECORD, homeAmount: { ...HOME, field: 'amount' } } }, + })); + expect(issues.map((i) => i.path)).toEqual([`${AT}.field`]); + expect(issues[0]!.message).toContain('declares no other field `amount`'); + }); + + it('refuses a pointer at a field that is not `currency` (a `number`, a currency `formula`)', () => { + for (const home of [ + { type: 'number', label: 'Amount (home)', readonly: true }, + { type: 'formula', label: 'Amount (home)', expression: 'record.amount', returnType: 'currency' }, + ]) { + const issues = objectIssues(invoice({ amount_home: home })); + expect(issues.map((i) => i.path), home.type).toEqual([`${AT}.field`]); + expect(issues[0]!.message).toMatch(new RegExp(`^\`amount\\.currencyConfig\\.homeAmount\\.field\` names \`amount_home\`, a \`${home.type}\` field`)); + } + }); + + it.each([ + ['fixed', { currencyMode: 'fixed', defaultCurrency: 'CNY' }], + ['record', { currencyMode: 'record', currencyField: 'currency_code' }], + ] as const)('refuses a `%s`-mode home field: metadata never names the home currency', (mode, currencyConfig) => { + const issues = objectIssues(invoice({ amount_home: { type: 'currency', label: 'Amount (home)', readonly: true, currencyConfig } })); + expect(issues.map((i) => i.path)).toEqual([`${AT}.field`]); + expect(issues[0]!.message).toMatch(new RegExp(`^\`amount\\.currencyConfig\\.homeAmount\\.field\` names \`amount_home\`, a \`${mode}\`-mode currency field`)); + }); + + it('refuses a home field another amount already stores into', () => { + const issues = objectIssues(invoice({ + tax: { type: 'currency', label: 'Tax', currencyConfig: RECORD }, + })); + expect(issues.map((i) => i.path)).toEqual(['fields.tax.currencyConfig.homeAmount.field']); + expect(issues[0]!.message).toContain('already the home field of `amount`'); + }); + }); + + describe('`homeAmount.rateDate`', () => { + it('refuses a pointer into nothing, naming the date candidates', () => { + const issues = objectIssues(invoice({ + amount: { type: 'currency', label: 'Amount', currencyConfig: { ...RECORD, homeAmount: { ...HOME, rateDate: 'doc_date' } } }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toBe(`${AT}.rateDate`); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount\.rateDate` names `doc_date`, but object `invoice` declares no field `doc_date`/); + expect(issues[0]!.message).toContain('candidates: `invoice_date`'); + }); + + it('refuses a pointer at a field that is not `date` / `datetime`, and says nothing defaults to `created_at`', () => { + const issues = objectIssues(invoice({ invoice_date: { type: 'text', label: 'Invoice date' } })); + expect(issues.map((i) => i.path)).toEqual([`${AT}.rateDate`]); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount\.rateDate` names `invoice_date`, a `text` field/); + expect(issues[0]!.message).toContain('nothing defaults to `created_at`'); + }); + }); + + describe('`homeAmount.rateField`', () => { + it('refuses a pointer into nothing', () => { + const issues = objectIssues(invoice({ amount_rate: undefined })); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toBe(`${AT}.rateField`); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount\.rateField` names `amount_rate`, but object `invoice` declares no field `amount_rate`/); + expect(issues[0]!.message).toContain('the object has none'); + }); + + it('refuses a pointer at a field that is not `number`', () => { + const issues = objectIssues(invoice({ amount_rate: { type: 'currency', label: 'Rate used', readonly: true } })); + expect(issues.map((i) => i.path)).toEqual([`${AT}.rateField`]); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount\.rateField` names `amount_rate`, a `currency` field/); + }); + + it('refuses a rate field shared with an amount that names another document date', () => { + const issues = objectIssues(invoice({ + due_date: { type: 'date', label: 'Due date' }, + tax: { type: 'currency', label: 'Tax', currencyConfig: { ...RECORD, homeAmount: { ...HOME, field: 'tax_home', rateDate: 'due_date' } } }, + tax_home: { type: 'currency', label: 'Tax (home)', readonly: true }, + })); + expect(issues.map((i) => i.path)).toEqual(['fields.tax.currencyConfig.homeAmount.rateField']); + expect(issues[0]!.message).toContain('already stores the rate of `amount`'); + }); + }); + + describe('the home and rate fields are platform-written (`readonly: true`, no `expression`)', () => { + it.each(['amount_home', 'amount_rate'] as const)('refuses `%s` without `readonly: true`', (name) => { + const issues = objectIssues(invoice({ [name]: { ...BASE_FIELDS[name], readonly: false } })); + const key = name === 'amount_home' ? 'field' : 'rateField'; + expect(issues.map((i) => i.path)).toEqual([`${AT}.${key}`]); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.message).toContain('does not declare `readonly: true`'); + expect(issues[0]!.message).toContain(`Add \`readonly: true\` to \`${name}\``); + }); + + it.each(['amount_home', 'amount_rate'] as const)('refuses `%s` declaring an `expression`', (name) => { + const issues = objectIssues(invoice({ [name]: { ...BASE_FIELDS[name], expression: 'record.amount' } })); + const key = name === 'amount_home' ? 'field' : 'rateField'; + expect(issues.map((i) => i.path)).toEqual([`${AT}.${key}`]); + expect(issues[0]!.message).toContain('declares an `expression`'); + }); + }); + + it('refuses `homeAmount` on a `formula` amount, at `homeAmount`, judging no pointer', () => { + const issues = objectIssues(invoice({ + amount: { type: 'formula', label: 'Net', expression: 'record.gross * 0.9', returnType: 'currency', currencyConfig: RECORD }, + gross: { type: 'currency', label: 'Gross' }, + amount_rate: undefined, + })); + expect(issues.map((i) => i.path)).toEqual([AT]); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount` is declared on a `formula` field of object `invoice`, which computes its value on read/); + }); +}); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index ba36c8e6613..c512ac59f82 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -545,9 +545,23 @@ const CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE: Readonly> * missing key; what the pointer NAMES is the object's to judge (object.zod.ts). */ function refuseRecordModeKeyMismatch( - config: { currencyMode: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string; currencyField?: string }, + config: { currencyMode: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string; currencyField?: string; homeAmount?: unknown }, ctx: z.RefinementCtx, ): void { + // [ADR-0144 D5] `homeAmount` converts a record's own currency into the home + // currency, so it has a reader only under `record`: a `fixed` amount has one + // currency for every row and a `dynamic` amount is already in the home + // currency, so the engine converts neither and the declaration would be inert. + if (config.currencyMode !== 'record' && config.homeAmount !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['homeAmount'], + message: + `\`homeAmount\` is read only under \`currencyMode: 'record'\`, and this config is \`${config.currencyMode}\`: ` + + 'only an amount whose records each carry their own currency is converted into the home currency. ' + + 'Set `currencyMode: \'record\'` with its `currencyField`, or delete `homeAmount`.', + }); + } if (config.currencyMode === 'record' && config.defaultCurrency !== undefined) { ctx.addIssue({ code: 'custom', @@ -589,6 +603,58 @@ const CURRENCY_CONFIG_FIXED_WITHOUT_CODE = + 'to display amounts in the tenant default currency (the `localization.currency` setting). ' + 'Since @objectstack/spec 18 nothing fills a code in; it used to be `\'CNY\'`.'; +/** The message an empty `homeAmount` pointer meets, naming the pointer and what it must name. */ +const homeAmountEmptyPointer = (key: string, names: string): string => + `\`homeAmount.${key}\` names a field: an empty name points at nothing — name ${names} on this object.`; + +/** + * The message a missing (or non-string) `homeAmount` pointer meets. ADR-0144 + * D5 requires all three whenever `homeAmount` is declared, and nothing fills + * one in, so the remedy is to name the field or to drop the declaration. + */ +const homeAmountPointerRequired = (key: string, names: string): (() => string) => () => + `\`homeAmount\` needs \`${key}\`, the name of ${names} on this object: the engine has no default for it. ` + + 'Name the field, or delete `homeAmount` if the amount needs no stored home-currency projection.'; + +/** + * [ADR-0144 D5] `homeAmount` — a `record`-mode amount's stored home-currency + * projection, declared by three pointers into the same object. At write the + * engine converts the amount at the rate of the record's document date and + * stores the result in `field` and the rate it used in `rateField` (ruling + * 6465431966 D1: "STORED with the rate used"). Every pointer is required: D8 + * rule 2 lets every currency-related field exist only as an author-declared + * field referenced by pointer, so the engine has no default to fall back on — + * no injected column, no fixed name, and no `created_at` as the document + * date. The names `rateDate` / `rateField` are this card's spelling (ADR-0144 + * Open item 1); the ADR corrects the drafted optional `rateField?` to required. + * + * This shape sees only names. What each one names — a `dynamic`-mode stored + * `currency` field, a `date` / `datetime` field, a `number` field, the home + * and rate fields `readonly` — is the object's to judge + * (`refuseDishonestHomeAmount`, object.zod.ts), because only the object holds + * the field map. Nothing reads the declaration until the engine's write-time + * projection ships: the key ships in the same release as that engine (ADR-0144 + * D9, release R2). + */ +const HomeAmountSchema = strictObject({ + surface: "this amount's `homeAmount` declaration", + history: + 'This shape was closed from the day it was declared — an unknown key here was never accepted, so a ' + + 'misspelled pointer cannot parse and leave the home amount, its date or its rate unwritten.', + aliases: { + homeField: 'field', target: 'field', amountField: 'field', + date: 'rateDate', dateField: 'rateDate', documentDate: 'rateDate', + rate: 'rateField', exchangeRate: 'rateField', rateUsed: 'rateField', + }, +}, { + field: z.string({ error: homeAmountPointerRequired('field', 'the `dynamic`-mode `currency` field that stores the home amount') }).min(1, homeAmountEmptyPointer('field', 'the `dynamic`-mode `currency` field that stores the home amount')) + .describe('The field on the same object that stores the amount converted into the home currency: a stored `currency` field in `dynamic` mode (it displays in `localization.currency`, so metadata never names the home currency), declaring `readonly: true` — only the engine writes it. Conventionally `AMOUNT_home`.'), + rateDate: z.string({ error: homeAmountPointerRequired('rateDate', 'the `date` / `datetime` field whose value picks the rate') }).min(1, homeAmountEmptyPointer('rateDate', 'the `date` / `datetime` field whose value picks the rate')) + .describe('The field on the same object holding the record\'s document date, whose value picks the exchange rate: a `date` or `datetime` field. Required, with no default — the engine never guesses the date (not `created_at`).'), + rateField: z.string({ error: homeAmountPointerRequired('rateField', 'the `number` field that stores the rate used') }).min(1, homeAmountEmptyPointer('rateField', 'the `number` field that stores the rate used')) + .describe('The field on the same object that stores the exchange rate the conversion used: a `number` field declaring `readonly: true` — only the engine writes it. Required: the home amount is stored with the rate used. Conventionally `AMOUNT_rate`.'), +}); + /** `fixed` ⇒ an authored `defaultCurrency`; `dynamic` reads none, so it needs none. */ function refuseFixedCurrencyWithoutCode( config: { currencyMode: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string }, @@ -673,12 +739,18 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({ // object.zod.ts) checks what it names, because only the object holds the // sibling field map. currencyField: z.string().min(1, '`currencyField` names a field: an empty name points at nothing — name the field on this object that holds each record\'s ISO 4217 code, conventionally `currency_code`.').optional().describe('`record` mode only, and required there: the name of the field on the same object that holds each record\'s currency — a `text` field declaring `valueDomain: \'iso_4217_currency\'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty.'), + // ADR-0144 D5 — the stored home-currency projection, declared by pointer. + // Optional; `refuseRecordModeKeyMismatch` below refuses it outside `record`, + // and the object's `superRefine` (`refuseDishonestHomeAmount`, + // object.zod.ts) checks what its three pointers name. + homeAmount: HomeAmountSchema.optional().describe('`record` mode only, optional: where the engine stores this amount converted into the organization\'s home currency (the `localization.currency` setting) at write, at the rate of the record\'s document date, and the rate it used. `field` names a `dynamic`-mode `currency` field, `rateDate` a `date` / `datetime` field and `rateField` a `number` field, all on the same object; all three are required, and the home and rate fields declare `readonly: true` because only the engine writes them. Consolidated totals aggregate the home field. Refused under `dynamic` and `fixed`.'), // #19992 — no `.overwrite()` here any more: the #7918 ISO 4217 contradiction // check and the #11423 default-materializing `.overwrite()` both existed only // for the removed `precision` key (see CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE // above). #27157 — `fixed` ⇒ a code; ADR-0144 D2 — `record` ⇔ a pointer and - // no code, both below. Parse output is `{ currencyMode }` plus the `defaultCurrency` and - // `currencyField` the author wrote. + // no code; ADR-0144 D5 — `homeAmount` under `record` only; all below. Parse + // output is `{ currencyMode }` plus the `defaultCurrency`, `currencyField` + // and `homeAmount` the author wrote. }).superRefine(refuseFixedCurrencyWithoutCode).superRefine(refuseRecordModeKeyMismatch)); /** diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 3e50383e5b5..bb901f145ec 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1938,6 +1938,206 @@ function refuseDishonestRecordCurrencyField( } } +/** The field types a `homeAmount.rateDate` pointer may name: a calendar date, with or without a time. */ +const HOME_AMOUNT_RATE_DATE_TYPES: ReadonlySet = new Set(['date', 'datetime']); + +/** + * [ADR-0144 D5] A `record`-mode amount's `currencyConfig.homeAmount` names + * three fields THIS object declares: `field`, where the engine stores the + * amount converted into the home currency; `rateDate`, the document date whose + * rate it converts at; and `rateField`, where it stores the rate it used + * (ruling 6465431966 D1, "STORED with the rate used"; D8 rule 2, every + * currency-related field "declared by the app author and referenced by + * pointer"). `CurrencyConfigSchema` sees one config and refuses the half it can + * see (`homeAmount` outside `record`, a missing or empty pointer); what the + * pointers name is judged here, beside + * {@link refuseDishonestRecordCurrencyField}, because only the object holds + * the field map. + * + * One located issue per offending pointer, at + * `['fields', , 'currencyConfig', 'homeAmount', ]`, each with + * its remedy and the object's candidate fields: + * + * - the amount is not a stored `currency` field — a `formula` computes on + * read and is never written, so nothing converts it at write (at + * `homeAmount` itself; the pointers are not judged further); + * - `field` is not another declared field, is not a stored `currency` field, + * or is one whose mode is not `dynamic` (D8 rule 5: "the home field is a + * `dynamic`-mode currency field", so metadata never names the home + * currency), or is already another amount's home field (one column, two + * writers); + * - `rateDate` is not a declared `date` / `datetime` field; + * - `rateField` is not a declared `number` field, or is shared with an amount + * that names another code field or another document date (the two would + * store two different rates into one column); + * - the home field or the rate field is not marked platform-written. The + * engine writes both, so each declares `readonly: true` — the existing mark + * that drops every non-system write of the field from the payload — and no + * `expression`, which would make the engine store a second, CEL-computed + * value in the same column. + * + * Judged against the AUTHORED (parsed) field map, so a field an extension or + * the registry adds is not a candidate. Not judged on `ObjectExtensionSchema`, + * for the reason {@link refuseDishonestRecordCurrencyField} gives. + */ +function refuseDishonestHomeAmount( + ownName: unknown, + fields: unknown, + ctx: z.RefinementCtx, +): void { + if (fields === null || typeof fields !== 'object') return; + const objectName = typeof ownName === 'string' ? ownName : ''; + type HomeAmount = { field?: unknown; rateDate?: unknown; rateField?: unknown }; + type CurrencyConfigView = { currencyMode?: unknown; currencyField?: unknown; homeAmount?: HomeAmount }; + type FieldView = { type?: unknown; readonly?: unknown; expression?: unknown; currencyConfig?: CurrencyConfigView } | null; + const map = fields as Record; + const has = (name: string): boolean => Object.prototype.hasOwnProperty.call(map, name); + const modeOf = (f: FieldView): unknown => f?.currencyConfig?.currencyMode ?? 'dynamic'; + const isHomeCandidate = (f: FieldView): boolean => f?.type === 'currency' && modeOf(f) === 'dynamic'; + const list = (names: string[], none: string): string => + names.length > 0 ? `candidates: ${names.map((c) => `\`${c}\``).join(', ')}.` : `the object has none; ${none}.`; + // The amounts that declare a home amount, in declaration order: the first + // claimant of a home field keeps it, a later one is told who holds it. + const declaring = Object.entries(map).filter(([, f]) => { + const config = f?.currencyConfig; + return config?.currencyMode === 'record' && config.homeAmount !== null && typeof config.homeAmount === 'object'; + }); + const homeOwner = new Map(); + const rateOwner = new Map(); + + for (const [name, f] of declaring) { + const config = f!.currencyConfig!; + const home = config.homeAmount!; + const base = ['fields', name, 'currencyConfig', 'homeAmount']; + const at = `\`${name}.currencyConfig.homeAmount`; + if (f?.type !== 'currency') { + ctx.addIssue({ + code: 'custom', + path: base, + message: `${at}\` is declared on a \`${String(f?.type)}\` field of object \`${objectName}\`, ` + + (f?.type === 'formula' + ? 'which computes its value on read and is never written, so nothing converts it at write. ' + + 'Declare `homeAmount` on the stored `currency` amount the formula reads, or delete it.' + : 'and only a stored `currency` amount is converted at write. Declare `homeAmount` on a `currency` ' + + 'field, or delete it.'), + }); + continue; + } + // A field the engine writes, and nobody else: `readonly: true` drops every + // non-system write of it, and no `expression` competes with the engine. + const refuseUnlessPlatformWritten = (key: 'field' | 'rateField', target: string, what: string): void => { + const t = map[target]; + if (t?.readonly === true && t.expression === undefined) return; + ctx.addIssue({ + code: 'custom', + path: [...base, key], + message: `${at}.${key}\` names \`${target}\`, ${what} only the engine writes, but it ` + + (t?.readonly === true + ? 'declares an `expression`, so the engine would store a second, computed value in the same column. ' + + `Delete \`expression\` from \`${target}\`.` + : 'does not declare `readonly: true`, so a client write could overwrite what the engine stored. ' + + `Add \`readonly: true\` to \`${target}\`.`), + }); + }; + + // `field` — a stored `dynamic`-mode `currency` sibling, claimed by this amount alone. + if (typeof home.field === 'string') { + const target = home.field; + const candidates = Object.keys(map).filter((k) => k !== name && isHomeCandidate(map[k]!) && !homeOwner.has(k)); + const remedy = `\`homeAmount.field\` must name a stored \`currency\` field of this object in \`dynamic\` mode — ` + + list(candidates, `declare one, conventionally \`${name}_home: Field.currency({ readonly: true })\``); + const t = map[target]; + if (target === name || !has(target)) { + ctx.addIssue({ + code: 'custom', + path: [...base, 'field'], + message: `${at}.field\` names \`${target}\`, but object \`${objectName}\` declares no other field \`${target}\`. ${remedy}`, + }); + } else if (t?.type !== 'currency') { + ctx.addIssue({ + code: 'custom', + path: [...base, 'field'], + message: `${at}.field\` names \`${target}\`, a \`${String(t?.type)}\` field on object \`${objectName}\`; the ` + + `home amount is stored, as an amount of money, in a \`currency\` field. ${remedy}`, + }); + } else if (modeOf(t) !== 'dynamic') { + ctx.addIssue({ + code: 'custom', + path: [...base, 'field'], + message: `${at}.field\` names \`${target}\`, a \`${String(modeOf(t))}\`-mode currency field on object ` + + `\`${objectName}\`; the home field is in \`dynamic\` mode, so it displays in the home currency ` + + `(\`localization.currency\`) and metadata never names that currency. Give \`${target}\` ` + + `\`currencyMode: 'dynamic'\` (or no \`currencyConfig\`), or point at another field. ${remedy}`, + }); + } else if (homeOwner.has(target)) { + ctx.addIssue({ + code: 'custom', + path: [...base, 'field'], + message: `${at}.field\` names \`${target}\`, which is already the home field of \`${homeOwner.get(target)}\`; ` + + `one column cannot store two converted amounts. ${remedy}`, + }); + } else { + homeOwner.set(target, name); + refuseUnlessPlatformWritten('field', target, 'the home field'); + } + } + + // `rateDate` — a declared `date` / `datetime` sibling. + if (typeof home.rateDate === 'string') { + const target = home.rateDate; + const t = map[target]; + if (!has(target) || typeof t?.type !== 'string' || !HOME_AMOUNT_RATE_DATE_TYPES.has(t.type)) { + const candidates = Object.keys(map).filter((k) => typeof map[k]?.type === 'string' + && HOME_AMOUNT_RATE_DATE_TYPES.has(map[k]!.type as string)); + ctx.addIssue({ + code: 'custom', + path: [...base, 'rateDate'], + message: `${at}.rateDate\` names \`${target}\`, ` + + (has(target) + ? `a \`${String(t?.type)}\` field on object \`${objectName}\`; the document date is a \`date\` or \`datetime\` field.` + : `but object \`${objectName}\` declares no field \`${target}\`.`) + + ' `homeAmount.rateDate` must name the record\'s document date, whose rate the amount converts at — ' + + list(candidates, 'declare one, e.g. `invoice_date: Field.date()`; nothing defaults to `created_at`'), + }); + } + } + + // `rateField` — a declared `number` sibling, shared only by amounts that store the same rate. + if (typeof home.rateField === 'string') { + const target = home.rateField; + const t = map[target]; + const holder = rateOwner.get(target); + if (!has(target) || t?.type !== 'number') { + const candidates = Object.keys(map).filter((k) => map[k]?.type === 'number'); + ctx.addIssue({ + code: 'custom', + path: [...base, 'rateField'], + message: `${at}.rateField\` names \`${target}\`, ` + + (has(target) + ? `a \`${String(t?.type)}\` field on object \`${objectName}\`; the rate used is stored in a \`number\` field.` + : `but object \`${objectName}\` declares no field \`${target}\`.`) + + ' `homeAmount.rateField` must name the field that stores the exchange rate the conversion used — ' + + list(candidates, `declare one, conventionally \`${name}_rate: Field.number({ readonly: true })\``), + }); + } else if (holder !== undefined && (holder.currencyField !== config.currencyField || holder.rateDate !== home.rateDate)) { + ctx.addIssue({ + code: 'custom', + path: [...base, 'rateField'], + message: `${at}.rateField\` names \`${target}\`, which already stores the rate of \`${holder.amount}\`, an ` + + 'amount with another code field or another document date, so the two rates can differ and one column ' + + `cannot store both. Give \`${name}\` its own \`number\` rate field, or the same \`currencyField\` and ` + + '`rateDate` as the amount it shares with.', + }); + } else if (holder === undefined) { + // The first holder is judged for the platform-written mark; a consistent + // sharer stores the same rate into the same, already judged, column. + rateOwner.set(target, { amount: name, currencyField: config.currencyField, rateDate: home.rateDate }); + refuseUnlessPlatformWritten('rateField', target, 'the rate field'); + } + } + } +} + /** * [objectui#12489 ruling 6203737086, letter A] A field's * `referenceDeletedWhen: { field, value }` names a SIBLING `select` field on @@ -3601,6 +3801,9 @@ const ObjectSchemaBase = strictObject( // [ADR-0144 D2] A `record`-mode amount's `currencyField` names a declared // ISO 4217 `text` sibling — the same door: only the object holds the map. refuseDishonestRecordCurrencyField(object.name, object.fields, ctx); + // [ADR-0144 D5] Its `homeAmount` names a `dynamic` currency home field, a + // date and a number rate field, the two written ones `readonly` — same door. + refuseDishonestHomeAmount(object.name, object.fields, ctx); // [objectui#12489 ruling 6203737086, letter A] A `referenceDeletedWhen` // predicate names a declared single-value `select` sibling and one of its // options — the same door: only the object holds the sibling map. diff --git a/scripts/adr-anchors/packages__spec__src__data__field.zod.ts.json b/scripts/adr-anchors/packages__spec__src__data__field.zod.ts.json index 056303ba6d1..5a421454897 100644 --- a/scripts/adr-anchors/packages__spec__src__data__field.zod.ts.json +++ b/scripts/adr-anchors/packages__spec__src__data__field.zod.ts.json @@ -4,5 +4,5 @@ "ADR-0120", "ADR-0144" ], - "invariant": "`UniqueScopeSchema` is the closed scope vocabulary `boolean | 'global' | 'organization'` (ADR-0120 D1). `'organization'` is the explicit synonym of field-level `true` — identical materialization, and `isUniqueDeclared` MUST count it (a word the vocabulary accepts but no driver reads is declarable-but-inert, ADR-0078). `'tenant'` and `'org'` are rejected, never aliased — the parse error names `'organization'` (§Terminology; PD #12: one contract, no dialects). The single-tenant story is D3's NULL-safe COALESCE bucket, NOT the retired 'composite degenerates to single-column' claim #5030 falsified.\n\nADR-0144 D2 — `CurrencyConfigSchema` has a third mode, `record`: the amount stays a bare number (ADR-0104 D1) and `currencyField` names the sibling field holding each record's ISO 4217 code. The config door (`refuseRecordModeKeyMismatch`) requires a non-empty `currencyField` under `record`, refuses it under `dynamic` / `fixed`, and refuses `defaultCurrency` under `record`. Each refused key would be declared but read by nothing (ADR-0078). Do NOT give `record` a `defaultCurrency` fallback or a platform-chosen code field name: per D8 rules 2 and 4, the code is the record's own, the field is the author's, and nothing fills either in. What the pointer NAMES is judged on the object (object.zod.ts), not here." + "invariant": "`UniqueScopeSchema` is the closed scope vocabulary `boolean | 'global' | 'organization'` (ADR-0120 D1). `'organization'` is the explicit synonym of field-level `true` — identical materialization, and `isUniqueDeclared` MUST count it (a word the vocabulary accepts but no driver reads is declarable-but-inert, ADR-0078). `'tenant'` and `'org'` are rejected, never aliased — the parse error names `'organization'` (§Terminology; PD #12: one contract, no dialects). The single-tenant story is D3's NULL-safe COALESCE bucket, NOT the retired 'composite degenerates to single-column' claim #5030 falsified.\n\nADR-0144 D2 — `CurrencyConfigSchema` has a third mode, `record`: the amount stays a bare number (ADR-0104 D1) and `currencyField` names the sibling field holding each record's ISO 4217 code. The config door (`refuseRecordModeKeyMismatch`) requires a non-empty `currencyField` under `record`, refuses it under `dynamic` / `fixed`, and refuses `defaultCurrency` under `record`. Each refused key would be declared but read by nothing (ADR-0078). Do NOT give `record` a `defaultCurrency` fallback or a platform-chosen code field name: per D8 rules 2 and 4, the code is the record's own, the field is the author's, and nothing fills either in. What the pointer NAMES is judged on the object (object.zod.ts), not here.\n\nADR-0144 D5 — `currencyConfig.homeAmount: { field, rateDate, rateField }` is read only under `record` (`refuseRecordModeKeyMismatch` refuses it under `dynamic` / `fixed`), and all three pointers are REQUIRED whenever it is declared, each non-empty (`HomeAmountSchema`, a closed shape). Do NOT make `rateField` or `rateDate` optional, and do NOT give either a default (`created_at`, an injected `AMOUNT_rate` column): D1 stores the home amount \"with the rate used\" and D8 rule 2 lets that rate and the document date live only in author-declared fields named by pointer. What the pointers NAME is judged on the object (object.zod.ts), not here." } diff --git a/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json b/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json index 76300265b17..df3a912578a 100644 --- a/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json +++ b/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json @@ -5,5 +5,5 @@ "ADR-0120", "ADR-0144" ], - "invariant": "ADR-0055 §1 — a `controlled_by_parent` object derives its master from EXACTLY ONE `master_detail` field declared `required: true`, and the parse refuses anything else (`refuseControlledByParentWithoutOneMaster`, in `ObjectSchemaBase`'s superRefine): no such field (a non-required `master_detail`, a required `lookup`, nothing) at `sharingModel`, two or more at `fields`. It runs on the PARSE, not only in `ObjectSchema.create()`, because the shapes it refuses were reachable through every door that skips the builder and the security runtime derives no master from them — so an object the parse accepted would be denied every read and refused every write. Do NOT move it back to `create()` only, and do NOT accept a second tier here (any `master_detail`, a required `lookup`): the runtime's two fallback tiers were retired under ADR-0049 with the step-18 semantic migration `cbp-master-relation-tiers-retired`, and a parse wider than the runtime is the three-seam disagreement that retirement closed.\n\n`IndexSchema.unique` scope contract (ADR-0120 D1, amending #3696): `'global'` = verbatim columns (no organization column injected); `'organization'` = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration; bare `true` = REFUSED since protocol 18 (#5082, ADR-0120 D7) with a prescription naming both words — it was the positional spelling of `'global'`; stored and built metadata converts it to `'global'` through the ADR-0087 conversion `declared-index-unique-scope` (byte-identical physical index), retired from the authoring funnel so a live author is refused, never converted silently. Field-level `unique: true` stays valid (D1) — never narrow `UniqueScopeSchema` in step with this one. Do NOT re-broaden the describe() back to 'true and global are synonyms, list the tenant column yourself' — that is the #4986 trap, and the hand-written composite is not NULL-safe (#5030).\n\nADR-0144 D2 — a `record`-mode amount's `currencyConfig.currencyField` must name ANOTHER declared field of the same object whose type may carry a value domain (`VALUE_DOMAIN_FIELD_TYPES`, `text` today) and which declares `valueDomain: 'iso_4217_currency'` (`refuseDishonestRecordCurrencyField`, in `ObjectSchemaBase`'s superRefine beside `refuseNonPictureImageField`). It runs on the parse because only the object holds the sibling field map, and it covers a `formula` with `returnType: 'currency'` in the same shape. Do NOT accept a pointer into nothing, at a non-code field, or at a text field without the domain: each lets a write store an amount whose currency is any string or none. It is deliberately not run on `ObjectExtensionSchema`, whose pointer may name a field only the owner declares." + "invariant": "ADR-0055 §1 — a `controlled_by_parent` object derives its master from EXACTLY ONE `master_detail` field declared `required: true`, and the parse refuses anything else (`refuseControlledByParentWithoutOneMaster`, in `ObjectSchemaBase`'s superRefine): no such field (a non-required `master_detail`, a required `lookup`, nothing) at `sharingModel`, two or more at `fields`. It runs on the PARSE, not only in `ObjectSchema.create()`, because the shapes it refuses were reachable through every door that skips the builder and the security runtime derives no master from them — so an object the parse accepted would be denied every read and refused every write. Do NOT move it back to `create()` only, and do NOT accept a second tier here (any `master_detail`, a required `lookup`): the runtime's two fallback tiers were retired under ADR-0049 with the step-18 semantic migration `cbp-master-relation-tiers-retired`, and a parse wider than the runtime is the three-seam disagreement that retirement closed.\n\n`IndexSchema.unique` scope contract (ADR-0120 D1, amending #3696): `'global'` = verbatim columns (no organization column injected); `'organization'` = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration; bare `true` = REFUSED since protocol 18 (#5082, ADR-0120 D7) with a prescription naming both words — it was the positional spelling of `'global'`; stored and built metadata converts it to `'global'` through the ADR-0087 conversion `declared-index-unique-scope` (byte-identical physical index), retired from the authoring funnel so a live author is refused, never converted silently. Field-level `unique: true` stays valid (D1) — never narrow `UniqueScopeSchema` in step with this one. Do NOT re-broaden the describe() back to 'true and global are synonyms, list the tenant column yourself' — that is the #4986 trap, and the hand-written composite is not NULL-safe (#5030).\n\nADR-0144 D2 — a `record`-mode amount's `currencyConfig.currencyField` must name ANOTHER declared field of the same object whose type may carry a value domain (`VALUE_DOMAIN_FIELD_TYPES`, `text` today) and which declares `valueDomain: 'iso_4217_currency'` (`refuseDishonestRecordCurrencyField`, in `ObjectSchemaBase`'s superRefine beside `refuseNonPictureImageField`). It runs on the parse because only the object holds the sibling field map, and it covers a `formula` with `returnType: 'currency'` in the same shape. Do NOT accept a pointer into nothing, at a non-code field, or at a text field without the domain: each lets a write store an amount whose currency is any string or none. It is deliberately not run on `ObjectExtensionSchema`, whose pointer may name a field only the owner declares.\n\nADR-0144 D5 — a `record`-mode amount's `currencyConfig.homeAmount` is judged on the parse (`refuseDishonestHomeAmount`, beside `refuseDishonestRecordCurrencyField`): the amount itself is a stored `currency` field (never a `formula`); `field` names ANOTHER declared `currency` field in `dynamic` mode (D8 rule 5: metadata never names the home currency) that no other amount already stores into; `rateDate` a declared `date` / `datetime` field; `rateField` a declared `number` field, shared only by amounts with the same `currencyField` and `rateDate`. The home and rate fields are platform-written and say so with the EXISTING mark, `readonly: true`, and carry no `expression`. Do NOT invent a new platform-computed key for them, and do NOT relax `readonly`: it is what drops a client's write of an engine-stored value." } From 75c7a09cfd7d1c5a27e4a5895ba543e22340b9e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:37:43 +0000 Subject: [PATCH 02/18] docs(skills): teach `currencyConfig.homeAmount`; changeset for the home-amount pointer The currency block of `objectstack-data/rules/field-types.md` gains the `homeAmount` example and one paragraph, paid for by deleting restatement in the same file (3207 -> 3181 tokens). The changeset declares `@objectstack/spec` and `@objectstack/skills` minor and marks the key as released with the write-time projection (ADR-0144 D9, R2). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .changeset/27191-home-amount-pointer.md | 40 ++++++++++++++++++++ skills/objectstack-data/rules/field-types.md | 33 ++++------------ 2 files changed, 47 insertions(+), 26 deletions(-) create mode 100644 .changeset/27191-home-amount-pointer.md diff --git a/.changeset/27191-home-amount-pointer.md b/.changeset/27191-home-amount-pointer.md new file mode 100644 index 00000000000..8661f5dcfc0 --- /dev/null +++ b/.changeset/27191-home-amount-pointer.md @@ -0,0 +1,40 @@ +--- +'@objectstack/spec': minor +'@objectstack/skills': minor +--- + +feat(spec): `currencyConfig.homeAmount: { field, rateDate, rateField }` — a record-mode amount declares its stored home-currency amount by pointer (ADR-0144 D5) + +Clause-②: yes (widening) +Metadata-protocol: added + +⛔ Not released alone: this ships in the same release as the write-time home-currency projection that fills these fields (ADR-0144 D9, release R2: "the `homeAmount` key never ships ahead of the engine"). + +An amount in `currencyMode: 'record'` can now declare where its value is stored after conversion into the organization's home currency (the `localization.currency` setting), which date picks the exchange rate, and where the rate used is stored. Consolidated totals then aggregate the home field like any other stored currency field. + +```ts +fields: { + currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }), + invoice_date: Field.date({ label: 'Invoice date' }), + amount: Field.currency({ + label: 'Amount', + currencyConfig: { + currencyMode: 'record', + currencyField: 'currency_code', + homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }, + }, + }), + amount_home: Field.currency({ label: 'Amount (home)', readonly: true }), + amount_rate: Field.number({ label: 'Rate used', readonly: true }), +} +``` + +- **Three required pointers.** When `homeAmount` is declared, `field`, `rateDate` and `rateField` must all be given, and none may be empty. There is no default, no injected column and no fixed field name. The document date is never taken from `created_at`. `homeAmount` is refused under `dynamic` and `fixed`. +- **What the pointers name, refused at parse with a remedy and the object's candidates.** Each refusal is located at `fields.AMOUNT.currencyConfig.homeAmount.POINTER`. + - `field` must name another `currency` field of the same object in `dynamic` mode (it displays in the home currency, so metadata never names that currency). No other amount may already store into it. + - `rateDate` must name a `date` or `datetime` field. + - `rateField` must name a `number` field. Two amounts may share it only when they have the same `currencyField` and `rateDate`. + - The amount itself must be a stored `currency` field. A `formula` amount is refused. +- **The home and rate fields are written by the platform only.** They must declare `readonly: true`, the existing mark that drops a client's write of the field, and no `expression`. +- **Skills:** `objectstack-data/rules/field-types.md` teaches `homeAmount`. Its field names (`amount_home`, `amount_rate`) are examples, not a contract. +- **Nothing to migrate.** `homeAmount` is new, and no existing config changes meaning. diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 019b2a657d4..f81eebb3240 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -6,8 +6,6 @@ - [Common Field Configurations](#common-field-configurations) - [Incorrect vs Correct](#incorrect-vs-correct) -Quick reference for choosing the right field type from the available options. - ## Two spellings: `Field.*` factory or object literal The tables below give the **object-literal** spelling. Real apps overwhelmingly @@ -144,16 +142,6 @@ Stored as JSON on the parent row — no separate table / FK: ## Common Field Configurations -### Text with Max Length - -```typescript -{ - type: 'text', - maxLength: 255, - required: true, -} -``` - ### Email with Uniqueness ```typescript @@ -174,7 +162,8 @@ Stored as JSON on the parent row — no separate table / FK: type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }, // ISO 4217 } -// per record: { currencyMode: 'record', currencyField: 'currency_code' } +// per record: { currencyMode: 'record', currencyField: 'currency_code', +// homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' } } ``` **Currency resolution.** The value is a bare number in every mode, never @@ -187,6 +176,11 @@ it may only restate the code, else `DATASET_INVALID` (delete it), as is a derive measure over two different `'fixed'` codes (derive from one currency or drop an operand). A `'fixed'` code is not judged against the tenant's. +`homeAmount` (`'record'` only, all three required): the engine stores the +home-currency amount in `field` (a `'dynamic'` `currency`) at the `rateDate` +(`date`) rate, and that rate in `rateField` (`number`), both `readonly: true`. +Cross-currency totals sum `field`. + ### Select with Default ```typescript @@ -208,8 +202,6 @@ operand). A `'fixed'` code is not judged against the tenant's. type: 'lookup', reference: 'account', required: true, - // Structured, picker-honoured filter — the former string[] `referenceFilters` - // was removed (ADR-0049): it filtered nothing. lookupFilters: [ { field: 'status', operator: 'eq', value: 'active' }, ], @@ -227,17 +219,6 @@ operand). A `'fixed'` code is not judged against the tenant's. } ``` -### Master-Detail with Cascade - -```typescript -{ - type: 'master_detail', - reference: 'invoice', - deleteBehavior: 'cascade', - required: true, -} -``` - ### Formula ```typescript From 6db7ac858462cf064bc412d8013ba18dc49bb060 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:09:40 +0000 Subject: [PATCH 03/18] chore(spec): regenerate the authorable surface, the field references and the strictness ledger for `homeAmount` Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/data/field.mdx | 12 +++++++++++- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- .../data.md | 12 ++++++------ packages/spec/authorable-surface/data.json | 1 + 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 000b5c65642..95382a1a8f7 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -31,6 +31,15 @@ const result = CurrencyConfigSchema.parse(data); | **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1). | | **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, and refused under `record`, where each record names its own. | | **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty. | +| **homeAmount** | `{ field: string; rateDate: string; rateField: string }` | optional | `record` mode only, optional: where the engine stores this amount converted into the organization's home currency (the `localization.currency` setting) at write, at the rate of the record's document date, and the rate it used. `field` names a `dynamic`-mode `currency` field, `rateDate` a `date` / `datetime` field and `rateField` a `number` field, all on the same object; all three are required, and the home and rate fields declare `readonly: true` because only the engine writes them. Consolidated totals aggregate the home field. Refused under `dynamic` and `fixed`. | + +### Nested Shape: `CurrencyConfig.homeAmount` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | The field on the same object that stores the amount converted into the home currency: a stored `currency` field in `dynamic` mode (it displays in `localization.currency`, so metadata never names the home currency), declaring `readonly: true` — only the engine writes it. Conventionally `AMOUNT_home`. | +| **rateDate** | `string` | ✅ | The field on the same object holding the record's document date, whose value picks the exchange rate: a `date` or `datetime` field. Required, with no default — the engine never guesses the date (not `created_at`). | +| **rateField** | `string` | ✅ | The field on the same object that stores the exchange rate the conversion used: a `number` field declaring `readonly: true` — only the engine writes it. Required: the home amount is stored with the rate used. Conventionally `AMOUNT_rate`. | --- @@ -90,7 +99,7 @@ const result = CurrencyConfigSchema.parse(data); | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -240,6 +249,7 @@ const result = CurrencyConfigSchema.parse(data); | **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1). | | **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, and refused under `record`, where each record names its own. | | **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty. | +| **homeAmount** | `{ field: string; rateDate: string; rateField: string }` | optional | `record` mode only, optional: where the engine stores this amount converted into the organization's home currency (the `localization.currency` setting) at write, at the rate of the record's document date, and the rate it used. `field` names a `dynamic`-mode `currency` field, `rateDate` a `date` / `datetime` field and `rateField` a `number` field, all on the same object; all three are required, and the home and rate fields declare `readonly: true` because only the engine writes them. Consolidated totals aggregate the home field. Refused under `dynamic` and `fixed`. | ### Nested Shape: `Field.conditionalFormatting[number]` diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 750b3ad0b84..3a95dd76b70 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -268,7 +268,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -631,7 +631,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 61d41509065..019723afe2a 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -102,7 +102,7 @@ Add a new field to an existing object | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -533,7 +533,7 @@ Add a new field to an existing object | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index 6d67334be3f..4af374d9628 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 154 | 88 | 2 | 0 | 64 | +| `data/` | 155 | 89 | 2 | 0 | 64 | ## `data/` — sites @@ -45,7 +45,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `driver/turso.zod.ts` | 2 | | `external-catalog.zod.ts` | 4 | | `field-value.zod.ts` | 4 | -| `field.zod.ts` | 13 | +| `field.zod.ts` | 14 | | `filter.zod.ts` | 13 | | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 8 | @@ -57,7 +57,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 2 | | `validation.zod.ts` | 6 | -| **total** | **154** | +| **total** | **155** | ## `data/` — open @@ -65,7 +65,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**64 strip of 154**, in 10 file(s). +**64 strip of 155**, in 10 file(s). | File | Strip | Sites | |---|---|---| @@ -74,12 +74,12 @@ over it is here. | `driver-sql.zod.ts` | 2 | 2 | | `driver.zod.ts` | 9 | 9 | | `external-catalog.zod.ts` | 4 | 4 | -| `field.zod.ts` | 1 | 13 | +| `field.zod.ts` | 1 | 14 | | `filter.zod.ts` | 11 | 13 | | `hook.zod.ts` | 5 | 8 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **64** | **154** | +| **total** | **64** | **155** | | Bucket | Sites | |---|---| diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 9b3731b9d4e..48dd5728c87 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -104,6 +104,7 @@ "data/CurrencyConfig:currencyField", "data/CurrencyConfig:currencyMode", "data/CurrencyConfig:defaultCurrency", + "data/CurrencyConfig:homeAmount", "data/DataEngineAggregateOptions:aggregations", "data/DataEngineAggregateOptions:context", "data/DataEngineAggregateOptions:filter", From 361f8f25cca4b3635c31de3e6fa187483700dcff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:42:45 +0000 Subject: [PATCH 04/18] test(spec): the field form omits `currencyConfig.homeAmount` until the engine writes it The form/Zod reconciliation gate refuses a key the Zod accepts and the form does not offer. `homeAmount` is offered in no form yet, deliberately: nothing writes the home amount until the write-time projection lands in the same release, so the gate's ledger carries a `planned` omit, the precedent of the other spec-first keys. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../system/metadata-form-zod-reconciliation.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 9897e447d00..7f756f885ed 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -365,6 +365,16 @@ const LEDGER: ReadonlyArray = [ key: 'conditionalFormatting', why: "planned, not yet rendered — a field's cell formatting rules (`[{ condition, style }]`, the list view's own rule element) are validated at parse and their CEL is judged at the authoring doors (it parses, it reads only `value` and `record`), but no renderer styles a cell from them until objectui's cell renderers read them, after the spec publishes. A form control offered today would let an author set a style nothing paints", }, + // [ADR-0144 D5] A record-mode amount's home-currency projection lands + // spec-first the same way: declared and judged at parse, written by nothing + // until the engine's write-time projection lands in the same release (D9). + { + kind: 'omit', + type: 'field', + path: 'currencyConfig', + key: 'homeAmount', + why: "planned, not yet written — the `{ field, rateDate, rateField }` pointers are validated at parse (all three required, each judged against the object's field map), but no engine converts the amount or stores the home amount and the rate until the write-time home-currency projection lands, which ADR-0144 D9 releases with this key. A form control offered today would let an author declare a home amount nothing writes", + }, { kind: 'omit', type: 'view', From 5b1430f663f3130854ec168a5822da08a35057f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:27:03 +0000 Subject: [PATCH 05/18] chore(spec): regenerate the authorable surface and references after merging main Main's side of the four regenerated files, re-derived from the merged tree so this branch's `homeAmount` rows sit beside main's new entries. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/data/field.mdx | 10 ++++++++++ content/docs/references/data/object.mdx | 7 ++++++- content/docs/references/system/migration.mdx | 4 ++++ packages/spec/authorable-surface/data.json | 2 ++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 95382a1a8f7..c8756a07779 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -70,6 +70,7 @@ const result = CurrencyConfigSchema.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -94,6 +95,7 @@ const result = CurrencyConfigSchema.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | @@ -232,6 +234,14 @@ const result = CurrencyConfigSchema.parse(data); | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing. | +### Nested Shape: `Field.lookupAutofill[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **target** | `string` | ✅ | Field of THIS object the pick fills (snake_case machine name). | +| **source** | `string` | ✅ | Field of the picked record — a field of this lookup's `reference` object — whose value is copied (snake_case machine name). | +| **overwrite** | `boolean` | optional | TRUE: the pick replaces whatever the target holds, a typed value included. Absent or FALSE: the pick fills the target only while it is empty or still holds the value an earlier pick filled in. | + ### Nested Shape: `Field.summaryOperations` | Property | Type | Required | Description | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 3a95dd76b70..f44f781b478 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -239,6 +239,7 @@ const result = ApiMethod.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -263,6 +264,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | @@ -382,7 +384,7 @@ const result = ApiMethod.parse(data); | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -502,6 +504,7 @@ const result = ApiMethod.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | +| **pressed** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Pressed-state predicate (CEL string or `{dialect, source}` envelope) over the same scope as `visible`: the action surface draws the button as already pressed for this reader (a toggle face, `aria-pressed`) while it evaluates TRUE. Display only — it never gates, greys or routes the action. A per-reader fact (e.g. the reader's own vote) is read from a block the object declares under `attachedOnRead`, as `has(record.viewer) && record.viewer.vote == 'helpful'`. A boolean literal is refused: a constant is a style, not a reader's state. Omit = never drawn pressed. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (`POST /actions/:object/:action` and MCP `run_action`) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a `type: api` action whose declared `target` is a same-origin platform path (exactly one leading `/`, followed by neither `/` nor a backslash, with a path after it) under that door; its `target` endpoint stays directly reachable, so it MUST still enforce this capability itself, the door's gate being the added check. A `type: api` action with an off-origin `target` is browser-only and not gated server-side. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | @@ -602,6 +605,7 @@ const result = ApiMethod.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -626,6 +630,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 019723afe2a..04dfb509d58 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -73,6 +73,7 @@ Add a new field to an existing object | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -97,6 +98,7 @@ Add a new field to an existing object | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | @@ -504,6 +506,7 @@ Add a new field to an existing object | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -528,6 +531,7 @@ Add a new field to an existing object | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 48dd5728c87..d04a1e830f1 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -315,6 +315,7 @@ "data/Field:internal", "data/Field:label", "data/Field:language", + "data/Field:lookupAutofill", "data/Field:lookupColumns", "data/Field:lookupFilters", "data/Field:lookupPageSize", @@ -328,6 +329,7 @@ "data/Field:multiple", "data/Field:name", "data/Field:options", + "data/Field:percentDisplay", "data/Field:picklist", "data/Field:placeholder", "data/Field:precision", From cf13692214fde39ab2a9b113f1708485e382e6b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:32:12 +0000 Subject: [PATCH 06/18] chore(docs): recount the unknown-key strictness ledger after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../data.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index 4af374d9628..a77032c64eb 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 155 | 89 | 2 | 0 | 64 | +| `data/` | 156 | 90 | 2 | 0 | 64 | ## `data/` — sites @@ -45,7 +45,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `driver/turso.zod.ts` | 2 | | `external-catalog.zod.ts` | 4 | | `field-value.zod.ts` | 4 | -| `field.zod.ts` | 14 | +| `field.zod.ts` | 15 | | `filter.zod.ts` | 13 | | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 8 | @@ -57,7 +57,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 2 | | `validation.zod.ts` | 6 | -| **total** | **155** | +| **total** | **156** | ## `data/` — open @@ -65,7 +65,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**64 strip of 155**, in 10 file(s). +**64 strip of 156**, in 10 file(s). | File | Strip | Sites | |---|---|---| @@ -74,12 +74,12 @@ over it is here. | `driver-sql.zod.ts` | 2 | 2 | | `driver.zod.ts` | 9 | 9 | | `external-catalog.zod.ts` | 4 | 4 | -| `field.zod.ts` | 1 | 14 | +| `field.zod.ts` | 1 | 15 | | `filter.zod.ts` | 11 | 13 | | `hook.zod.ts` | 5 | 8 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **64** | **155** | +| **total** | **64** | **156** | | Bucket | Sites | |---|---| From b71e44a7da455c40757e918658430670b53fc4ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:07:07 +0000 Subject: [PATCH 07/18] wip(objectql): home-amount projection module; register ERR_EXCHANGE_RATE_MISSING and ERR_HOME_CURRENCY_UNSET; export CURRENCY_FRACTION_DIGITS (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude --- .../objectql/src/home-amount-projection.ts | 453 ++++++++++++++++++ .../spec/src/api/error-code-ledger.zod.ts | 16 + packages/spec/src/data/index.ts | 6 + 3 files changed, 475 insertions(+) create mode 100644 packages/objectql/src/home-amount-projection.ts diff --git a/packages/objectql/src/home-amount-projection.ts b/packages/objectql/src/home-amount-projection.ts new file mode 100644 index 00000000000..74cf0676744 --- /dev/null +++ b/packages/objectql/src/home-amount-projection.ts @@ -0,0 +1,453 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D5, D7 parameters 3 and 4] The write-time home projection: a + * `record`-mode amount declaring `currencyConfig.homeAmount` is converted into + * the organization's home currency when it is written, and the result is + * stored in the declared home field together with the rate it used. + * + * ## What one projection computes + * + * For each declaration, over the row IMAGE the write stores (the insert row + * after every hook and strip, or the stored row with the final update payload + * laid over it): + * + * - **A blank amount** stores a blank home amount and a blank rate. There is + * nothing to convert, so nothing is looked up and nothing is refused. + * - **The home currency** is the organization's `localization.currency` + * setting, read through the host. Unset, the write is refused + * `ERR_HOME_CURRENCY_UNSET` / 400 (ADR-0144 Open item 4): the sources rule + * out a silent default, so the engine never picks one. + * - **The same currency as home** stores rate `1` and the amount rounded to + * the home currency's minor units, with no rate lookup and no rate row + * needed (Open item 6). + * - **A foreign currency** reads the rate row of that currency with the latest + * `effective_date` on or before the record's document date (Open item 2): + * never a row dated after the document, and never "the most recent row". + * None ⇒ the write is refused `ERR_EXCHANGE_RATE_MISSING` / 400, naming the + * currency and the date (D5: "a missing rate refuses the write"; no 1:1 + * rate, no nearest row, no null home amount is stored in its place). + * - **The arithmetic** is parameter 3: "1 home unit = `rate` units of the + * foreign currency", so the home amount is the record amount DIVIDED by the + * rate. It is exact decimal arithmetic on the decimal spellings of the two + * numbers (scaled integers, never a floating-point division), rounded once, + * half away from zero, to the home currency's ISO 4217 minor units read from + * `CURRENCY_FRACTION_DIGITS` (Open item 3): JPY 0, USD 2, KWD 3. + * - **The rate stored** is the rate row's own value, never rounded (D1: the + * amount is stored "with the rate used"; a rounded rate would not reproduce + * the stored home amount). A rate field whose declared `scale` keeps fewer + * decimals than the rate carries refuses the write at the record + * validator's `max_scale` seam, as every number field does; the engine + * never rounds a value to fit a field. + * + * ## Who writes the two fields + * + * Only this projection. The spec makes the home and rate fields declare + * `readonly: true`, so a caller's value for either is stripped before the + * projection runs; the projection writes AFTER every strip, onto the payload + * the driver stores, so its own values are never stripped. It also runs after + * the field defaults, so a `defaultValue` on either field never stands: the + * projection overwrites it. A writer the strips do not judge (a system write, + * an update under `preserveAudit`) that sends the home or rate field triggers + * the projection, which recomputes over it: the stored home amount is always + * the declared conversion of the stored amount, never a copied-in number. A + * corrected rate row reaches stored amounts only through an explicit + * recompute, because an update that touches none of the amount, its code, its + * document date or the two projected fields does not project at all (D5). + */ + +import { isValueDomainMember } from '@objectstack/spec/shared'; +import { CURRENCY_FRACTION_DIGITS } from '@objectstack/spec/data'; +import { isRecordCurrencyBlank, recordCurrencyFieldOf } from './validation/record-currency-code.js'; +import { ValidationError } from './validation/record-validator.js'; + +/** The object the rates are read from (ADR-0144 D3). */ +export const EXCHANGE_RATE_OBJECT = 'sys_exchange_rate'; + +/** The ADR-0112 `code` {@link ExchangeRateMissingError} carries. */ +export const EXCHANGE_RATE_MISSING_CODE = 'ERR_EXCHANGE_RATE_MISSING' as const; + +/** The ADR-0112 `code` {@link HomeCurrencyUnsetError} carries. */ +export const HOME_CURRENCY_UNSET_CODE = 'ERR_HOME_CURRENCY_UNSET' as const; + +/** One `homeAmount` declaration, with the code field its amount is in. */ +export interface HomeAmountPlanEntry { + /** The `record`-mode `currency` field declaring `homeAmount`. */ + readonly amountField: string; + /** Its `currencyConfig.currencyField`: the ISO 4217 code of the amount. */ + readonly codeField: string; + /** `homeAmount.field`: where the home amount is stored. */ + readonly homeField: string; + /** `homeAmount.rateDate`: the document date that picks the rate. */ + readonly rateDateField: string; + /** `homeAmount.rateField`: where the rate used is stored. */ + readonly rateField: string; +} + +/** + * Every `homeAmount` declaration of an object's field map, in declaration + * order. Read off the registered declaration: the spec already refused a + * pointer at a missing or wrong-typed field, so a declaration here names + * fields that exist. Empty for an object declaring none (the common case), so + * an ordinary write pays one loop over its field names. + */ +export function planHomeAmounts(schema: unknown): HomeAmountPlanEntry[] { + const fields = (schema as { fields?: unknown } | null | undefined)?.fields; + if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) return []; + const plan: HomeAmountPlanEntry[] = []; + for (const [amountField, def] of Object.entries(fields as Record)) { + const codeField = recordCurrencyFieldOf(def); + if (codeField === undefined) continue; + const home = (def as { currencyConfig?: { homeAmount?: unknown } }).currencyConfig?.homeAmount; + if (home === null || typeof home !== 'object') continue; + const { field, rateDate, rateField } = home as { field?: unknown; rateDate?: unknown; rateField?: unknown }; + if (typeof field !== 'string' || typeof rateDate !== 'string' || typeof rateField !== 'string') continue; + plan.push({ amountField, codeField, homeField: field, rateDateField: rateDate, rateField }); + } + return plan; +} + +/** The keys whose presence in an update payload makes the projection run for `entry`. */ +function projectionInputs(entry: HomeAmountPlanEntry): readonly string[] { + return [entry.amountField, entry.codeField, entry.rateDateField, entry.homeField, entry.rateField]; +} + +/** + * The entries an update payload moves: it carries the amount, its code, its + * document date, or one of the two projected fields. Only these are projected + * on an update, so an edit of any other field never re-reads a rate (a + * corrected rate reaches stored rows only through an explicit recompute). + */ +export function homeAmountsTouched( + plan: readonly HomeAmountPlanEntry[], + payload: Record | null | undefined, +): HomeAmountPlanEntry[] { + if (!payload || plan.length === 0) return []; + return plan.filter((entry) => projectionInputs(entry).some((key) => Object.prototype.hasOwnProperty.call(payload, key))); +} + +// ── Exact decimal arithmetic ────────────────────────────────────────────── + +/** A decimal as a scaled integer: the value is `digits / 10^scale`. */ +export interface ScaledDecimal { + readonly digits: bigint; + readonly scale: number; +} + +const DECIMAL_SPELLING = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/; + +/** + * The exact decimal a stored or written number spells, or `undefined` when it + * spells none. A JavaScript number is read through its shortest round-trip + * spelling (`String(0.1389)` is `'0.1389'`), which is the decimal the writer + * wrote; a string (a driver answering an exact-decimal column as text) is read + * as written. Exponent spellings are accepted. + */ +export function toScaledDecimal(value: unknown): ScaledDecimal | undefined { + let text: string; + if (typeof value === 'number') { + if (!Number.isFinite(value)) return undefined; + text = String(value); + } else if (typeof value === 'bigint') { + text = value.toString(); + } else if (typeof value === 'string') { + text = value.trim(); + } else { + return undefined; + } + const m = DECIMAL_SPELLING.exec(text); + if (!m) return undefined; + const [, sign, int = '', frac = '', exp] = m; + if (int === '' && frac === '') return undefined; + let digits = BigInt(`${int}${frac}` || '0'); + let scale = frac.length - (exp === undefined ? 0 : Number(exp)); + if (scale < 0) { + digits *= 10n ** BigInt(-scale); + scale = 0; + } + return { digits: sign === '-' ? -digits : digits, scale }; +} + +const abs = (n: bigint): bigint => (n < 0n ? -n : n); + +/** + * `numerator / denominator`, rounded to an integer half away from zero — + * the one rounding step of the projection. + */ +function divideRoundHalfAwayFromZero(numerator: bigint, denominator: bigint): bigint { + const negative = (numerator < 0n) !== (denominator < 0n); + const n = abs(numerator); + const d = abs(denominator); + let q = n / d; + if ((n % d) * 2n >= d) q += 1n; + return negative ? -q : q; +} + +/** + * `amount ÷ rate`, rounded once, half away from zero, to `places` decimals — + * parameter 3 as arithmetic. Both operands are exact; so is every + * intermediate. + */ +export function divideToPlaces(amount: ScaledDecimal, rate: ScaledDecimal, places: number): ScaledDecimal { + // (a / 10^as) / (r / 10^rs) × 10^places = a × 10^(rs + places) / (r × 10^as) + const numerator = amount.digits * 10n ** BigInt(rate.scale + places); + const denominator = rate.digits * 10n ** BigInt(amount.scale); + return { digits: divideRoundHalfAwayFromZero(numerator, denominator), scale: places }; +} + +/** The canonical decimal spelling of a scaled decimal (`-1234.50` for `{ -123450n, 2 }`). */ +export function formatScaledDecimal(value: ScaledDecimal): string { + const text = abs(value.digits).toString().padStart(value.scale + 1, '0'); + const body = value.scale === 0 ? text : `${text.slice(0, -value.scale)}.${text.slice(-value.scale)}`; + return value.digits < 0n ? `-${body}` : body; +} + +/** + * The number the home field stores: the rounded decimal's own spelling read + * back as a number. A currency value is a bare number (ADR-0104 D1); the + * arithmetic that produced it never touched a float. + */ +function toStoredNumber(value: ScaledDecimal): number { + const n = Number(formatScaledDecimal(value)); + return Object.is(n, -0) ? 0 : n; +} + +// ── The document date ───────────────────────────────────────────────────── + +const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * The calendar date a document-date value names, as `YYYY-MM-DD`, or + * `undefined` when it names none. A `date` value is its own calendar date. A + * `datetime` value is an instant, and its calendar date is taken in UTC — the + * one reading that gives every write path (a REST write, an import, a flow) + * the same rate for the same stored instant, whatever zone the writer's + * request carried. + */ +export function documentDateOf(value: unknown): string | undefined { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10); + if (typeof value !== 'string') return undefined; + const text = value.trim(); + if (CALENDAR_DATE.test(text)) return text; + const ms = Date.parse(text); + return Number.isNaN(ms) ? undefined : new Date(ms).toISOString().slice(0, 10); +} + +// ── What the projection reads ───────────────────────────────────────────── + +/** The rate row the projection picked. */ +export interface ExchangeRateRow { + readonly rate: unknown; + readonly effectiveDate: string | undefined; +} + +/** + * What a projection reads from outside the row, for one organization. The + * engine supplies it per write, memoized, so a batch of N rows in one + * currency and date asks the rate table once. + */ +export interface HomeAmountServing { + /** The organization's `localization.currency`, or `undefined` when unset. */ + homeCurrency(organizationId: string | undefined): Promise; + /** The `currency` row with the latest `effective_date` on or before `onOrBefore`, or `undefined`. */ + rateOn(organizationId: string | undefined, currency: string, onOrBefore: string): Promise; +} + +/** Wrap a serving so each (organization, currency, date) and each organization's home currency is read once. */ +export function memoizeHomeAmountServing(serving: HomeAmountServing): HomeAmountServing { + const homes = new Map>(); + const rates = new Map>(); + return { + homeCurrency(organizationId) { + const key = organizationId ?? ''; + let hit = homes.get(key); + if (!hit) { + hit = serving.homeCurrency(organizationId); + homes.set(key, hit); + } + return hit; + }, + rateOn(organizationId, currency, onOrBefore) { + const key = `${organizationId ?? ''}\u0000${currency}\u0000${onOrBefore}`; + let hit = rates.get(key); + if (!hit) { + hit = serving.rateOn(organizationId, currency, onOrBefore); + rates.set(key, hit); + } + return hit; + }, + }; +} + +// ── The refusals ────────────────────────────────────────────────────────── + +const verb = (operation: 'insert' | 'update') => (operation === 'insert' ? 'Insert' : 'Update'); +const rowPhrase = (recordId: unknown) => (recordId === undefined || recordId === null ? '' : ` (record ${String(recordId)})`); + +/** + * `ERR_EXCHANGE_RATE_MISSING` / 400 — no rate row for the record's currency + * is in force on its document date (ADR-0144 D5, Open item 9). Identified by + * `code`, which survives crossing package boundaries. + */ +export class ExchangeRateMissingError extends Error { + readonly code = EXCHANGE_RATE_MISSING_CODE; + readonly status = 400; + /** The same number under ADR-0112 D5's spelling. */ + readonly httpStatus = 400; + /** The object written. */ + readonly object: string; + /** The amount that could not be converted. */ + readonly field: string; + /** Its currency: the code of the rate row that is missing. */ + readonly currency: string; + /** The document date, `YYYY-MM-DD`: no row is in force on or before it. */ + readonly date: string; + /** The organization's home currency the amount was to be converted into. */ + readonly homeCurrency: string; + + constructor( + object: string, + operation: 'insert' | 'update', + entry: HomeAmountPlanEntry, + currency: string, + date: string, + homeCurrency: string, + recordId?: unknown, + ) { + super( + `${verb(operation)} on '${object}'${rowPhrase(recordId)} was REFUSED: '${entry.amountField}' is in ${currency} ` + + `and is converted into the home currency ${homeCurrency} at the rate in force on its document date ` + + `('${entry.rateDateField}' = ${date}), and no exchange rate for ${currency} has an effective date on or ` + + `before ${date}. The row was not written. Add an exchange rate for ${currency} effective on or before ` + + `${date} (Setup → Exchange Rates; a rate states how many ${currency} one ${homeCurrency} buys), then write ` + + `again. A missing rate is never replaced by 1, by a later rate, or by an empty home amount.`, + ); + this.name = 'ExchangeRateMissingError'; + this.object = object; + this.field = entry.amountField; + this.currency = currency; + this.date = date; + this.homeCurrency = homeCurrency; + } +} + +/** + * `ERR_HOME_CURRENCY_UNSET` / 400 — the organization has no home currency, so + * an amount declaring a home amount cannot be converted (ADR-0144 Open item + * 4). Identified by `code`. + */ +export class HomeCurrencyUnsetError extends Error { + readonly code = HOME_CURRENCY_UNSET_CODE; + readonly status = 400; + /** The same number under ADR-0112 D5's spelling. */ + readonly httpStatus = 400; + /** The object written. */ + readonly object: string; + /** The amount whose home amount could not be computed. */ + readonly field: string; + /** The setting to fill, `namespace.key`. */ + readonly setting = 'localization.currency'; + + constructor(object: string, operation: 'insert' | 'update', entry: HomeAmountPlanEntry, held: string | undefined, recordId?: unknown) { + const state = held === undefined + ? 'the organization has no home currency: the setting Localization → Default currency (localization.currency) is not set' + : `the organization's home currency setting Localization → Default currency (localization.currency) holds '${held}', which is not an ISO 4217 currency code`; + super( + `${verb(operation)} on '${object}'${rowPhrase(recordId)} was REFUSED: '${entry.amountField}' stores its amount ` + + `converted into the home currency in '${entry.homeField}', and ${state}. The row was not written. ` + + `Set Localization → Default currency to the organization's home currency (for example USD), then write ` + + `again. The platform never assumes a home currency.`, + ); + this.name = 'HomeCurrencyUnsetError'; + this.object = object; + this.field = entry.amountField; + } +} + +/** Is this one of the projection's own refusals? */ +export function isHomeAmountRefusal(e: unknown): e is ExchangeRateMissingError | HomeCurrencyUnsetError { + return e instanceof ExchangeRateMissingError || e instanceof HomeCurrencyUnsetError; +} + +// ── The projection ──────────────────────────────────────────────────────── + +const isIsoCurrency = (code: string): boolean => isValueDomainMember('iso_4217_currency', code); + +/** Where a projection runs: whose organization, which operation, which record. */ +export interface HomeAmountWrite { + readonly object: string; + readonly operation: 'insert' | 'update'; + readonly organizationId: string | undefined; + readonly recordId?: unknown; +} + +/** + * Project every entry of `plan` over one row `image` and answer the values to + * write: for each entry, its home field and its rate field. Reads through + * `serving`; never mutates `image`. + * + * An entry whose inputs another check refuses is left out, so that check + * answers instead of a rate lookup over a value that will never be stored: a + * blank code (`ERR_CURRENCY_CODE_REQUIRED`), a code that is not ISO 4217 or + * an amount or document date that does not parse (the record validator). + * + * @throws HomeCurrencyUnsetError no usable `localization.currency` + * @throws ExchangeRateMissingError no rate row in force on the document date + * @throws ValidationError a foreign amount with a blank document date + */ +export async function projectHomeAmounts( + plan: readonly HomeAmountPlanEntry[], + image: Record, + serving: HomeAmountServing, + write: HomeAmountWrite, +): Promise> { + const out: Record = {}; + for (const entry of plan) { + const amountValue = image[entry.amountField]; + if (isRecordCurrencyBlank(amountValue)) { + out[entry.homeField] = null; + out[entry.rateField] = null; + continue; + } + const amount = toScaledDecimal(amountValue); + const codeValue = image[entry.codeField]; + if (amount === undefined || isRecordCurrencyBlank(codeValue) || typeof codeValue !== 'string') continue; + const code = codeValue.trim(); + if (!isIsoCurrency(code)) continue; + + const homeValue = await serving.homeCurrency(write.organizationId); + const home = typeof homeValue === 'string' && homeValue.trim() !== '' ? homeValue.trim() : undefined; + if (home === undefined || !isIsoCurrency(home)) { + throw new HomeCurrencyUnsetError(write.object, write.operation, entry, home, write.recordId); + } + const places = CURRENCY_FRACTION_DIGITS[home]!; + + if (code === home) { + // Open item 6: the same currency as home converts at 1, with no lookup. + out[entry.homeField] = toStoredNumber(divideToPlaces(amount, { digits: 1n, scale: 0 }, places)); + out[entry.rateField] = 1; + continue; + } + + const dateValue = image[entry.rateDateField]; + if (isRecordCurrencyBlank(dateValue)) { + throw new ValidationError([{ + field: entry.rateDateField, + code: 'required', + message: `'${entry.rateDateField}' is required to convert '${entry.amountField}' (${code}) into the home ` + + `currency ${home}: the exchange rate is the one in force on this date`, + }]); + } + const date = documentDateOf(dateValue); + if (date === undefined) continue; + + const row = await serving.rateOn(write.organizationId, code, date); + const rate = row ? toScaledDecimal(row.rate) : undefined; + if (!row || rate === undefined || rate.digits <= 0n) { + throw new ExchangeRateMissingError(write.object, write.operation, entry, code, date, home, write.recordId); + } + out[entry.homeField] = toStoredNumber(divideToPlaces(amount, rate, places)); + out[entry.rateField] = typeof row.rate === 'number' ? row.rate : Number(formatScaledDecimal(rate)); + } + return out; +} diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 3754c1e4b15..a3c4600130b 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -801,12 +801,28 @@ export const ERROR_CODE_LEDGER = { 'ERR_CURRENCY_CODE_REQUIRED', 'ERR_DATASOURCE_UNAVAILABLE', 'ERR_DRIVER_CONNECT', + // [ADR-0144 D5, Open items 2 and 9] A write would store a `record`-mode + // amount declaring `homeAmount` in a foreign currency, and no exchange rate + // for that currency is in force on the record's document date (the latest + // `effective_date` on or before it). 400; the driver is never asked to write + // the row (`ExchangeRateMissingError`, `home-amount-projection.ts`); the + // message names the currency and the date. No 1:1 rate, later rate or empty + // home amount is stored in its place. Fixed by adding a rate row. + 'ERR_EXCHANGE_RATE_MISSING', // [#8846] a `before*` hook moved or cleared the id the engine resolved a // by-id (or per-row predicate) write against — refused rather than // silently writing a different record (`HOOK_TARGET_REBIND_ERROR_CODE`, // `hook-target-rebind-errors.ts`), during a write the dispatcher is // serving. Same #8087-gate family. 'ERR_HOOK_TARGET_REBIND', + // [ADR-0144 Open item 4] A write would store a `record`-mode amount + // declaring `homeAmount`, and the organization has no usable home currency: + // `localization.currency` is unset (or holds no ISO 4217 code). 400; the + // driver is never asked to write the row (`HomeCurrencyUnsetError`, + // `home-amount-projection.ts`). The platform never assumes a home currency. + // Fixed by setting `localization.currency` — a different remedy from + // `ERR_EXCHANGE_RATE_MISSING`, hence its own code. + 'ERR_HOME_CURRENCY_UNSET', // [#5320] Third EMITTER of the code (metadata-protocol and plugin-security // already register it) — the registration loop's `views:` tighten refuses a // non-container entry, and the `viewItems:` channel refuses an entry the diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index fc388d94a58..beb611aafc9 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -180,6 +180,12 @@ export * from './api-derivation'; // so the two can never drift apart (#7521). export * from './managed-api-affordance'; export * from './field.zod'; +// [ADR-0144 Open item 3] The ISO 4217 minor units per currency — the checked-in +// CLDR snapshot whose key set is the `iso_4217_currency` value domain. Exported +// for its first digit reader: objectql's write-time home projection rounds a +// home amount to the home currency's minor units with it, from this one table +// rather than a second copy or a runtime `Intl` probe. +export * from './currency-fraction-digits'; // The credential read mask (ADR-0100) — the ONE string a masked read serves, in // place of the two byte-identical literals objectql and service-settings each // declared until #7572. Both now import this one, so the mask a console sees on From 86544d460fac3e62ae6400cc3573cd5a6ab7910a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:09:33 +0000 Subject: [PATCH 08/18] wip(objectql): wire the home projection into insert, by-id and predicate update, validate() preview; bridge localization.currency (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude --- packages/objectql/src/engine-validate.ts | 14 +- packages/objectql/src/engine.ts | 216 ++++++++++++++++++ .../objectql/src/home-amount-projection.ts | 42 ++++ packages/objectql/src/plugin.ts | 24 ++ 4 files changed, 294 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/engine-validate.ts b/packages/objectql/src/engine-validate.ts index 7659524e34f..ccdef5f4bc5 100644 --- a/packages/objectql/src/engine-validate.ts +++ b/packages/objectql/src/engine-validate.ts @@ -51,6 +51,7 @@ import { validateRecordInScope, normalizeBlankTypedValues, normalizeNumericStrin 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 { isHomeAmountRefusal } from './home-amount-projection.js'; import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from './engine-update-dispatch.js'; import { droppedFieldEvents, @@ -93,11 +94,12 @@ export interface ValidateHost { resolveOptionPermissions: ObjectQL['resolveOptionPermissions']; getSummaryDescriptors: ObjectQL['getSummaryDescriptors']; assertReferencesResolve: ObjectQL['assertReferencesResolve']; + previewHomeAmounts: ObjectQL['previewHomeAmounts']; } /** The body of `ObjectQL#validate`; the method's docblock there is its contract. */ export async function validate( - this: Pick, + this: Pick, object: string, data: Record | Record[], options?: { @@ -563,6 +565,14 @@ export async function validate( object, (schemaForValidation as { fields?: unknown } | undefined)?.fields, mode === 'insert' ? row : judgedViews[i], mode, mode === 'insert' ? undefined : row, storedIds[i], ); + // [ADR-0144 D5] The write's home projection, on the same image and + // under the same condition: a row the write refuses for no home + // currency or no rate on its document date (or a blank document date) + // is refused here with the same code, so a dry run does not pass it. + // It reads the rate table and the home currency; it writes nothing. + await this.previewHomeAmounts( + object, schemaForValidation, mode, row, mode === 'insert' ? row : judgedViews[i], options?.context, storedIds[i], + ); } warnings.push(...evaluateValidationRules(schemaForValidation as any, row, mode, { // [#22445] The stored row, when this row named one: the by-id @@ -593,7 +603,7 @@ export async function validate( 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) { + if (e instanceof RecordCurrencyCodeRequiredError || isHomeAmountRefusal(e)) { return { valid: false, errors: [{ field: e.field, code: e.code, message: e.message }], warnings }; } throw e; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 34f177e5fa9..a983966d2a5 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -315,6 +315,16 @@ import { assertProjectionNamesAField, refuseProjectionOfUnknownColumns } from '. 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 { + EXCHANGE_RATE_OBJECT, + homeAmountsTouched, + memoizeHomeAmountServing, + planHomeAmounts, + projectHomeAmounts, + settleBulkHomeAmounts, + type HomeAmountPlanEntry, + type HomeAmountServing, +} from './home-amount-projection.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'; @@ -9654,6 +9664,134 @@ export class ObjectQL implements IObjectQLEngine { this.logger.info('I18nService configured for validation messages'); } + /** + * [ADR-0144 D4, Open item 4] Where the write-time home projection reads an + * organization's home currency: its `localization.currency` setting, or + * `undefined` when unset. Bridged by `ObjectQLPlugin`, which resolves the + * settings service when a write asks, not when it starts. With no resolver + * every organization's home currency is unset, so a write of an amount + * declaring `homeAmount` is refused `ERR_HOME_CURRENCY_UNSET` — the engine + * never assumes one. + */ + setHomeCurrencyResolver(resolver: ((organizationId: string | undefined) => Promise) | undefined): void { + this.homeCurrencyResolver = resolver; + } + + private homeCurrencyResolver?: (organizationId: string | undefined) => Promise; + + /** + * [ADR-0144 D5, Open item 2] What one write's home projection reads, memoized + * for that write: each organization's home currency once, and each + * (organization, currency, document date) rate once, however many rows ask. + * + * The rate is the `sys_exchange_rate` row of that currency with the latest + * `effective_date` on or before the document date, in the organization the + * row lands in. Read `sudo()`-shaped (`{ ...context, isSystem: true }`): a + * rank-and-file writer cannot read the rate table (parameter 2 makes it an + * administrator's object), yet every write of theirs is converted through + * it; the caller's transaction rides along, and `tenantId` is the row's own + * organization, so one organization's rate never converts another's + * amount. An install without the rate object registered has no rate for any + * currency. + */ + private homeAmountServing(context: ExecutionContext | undefined): HomeAmountServing { + return memoizeHomeAmountServing({ + homeCurrency: async (organizationId) => this.homeCurrencyResolver?.(organizationId), + rateOn: async (organizationId, currency, onOrBefore) => { + if (!this._registry.getObject(EXCHANGE_RATE_OBJECT)) return undefined; + const rows = await this.find(EXCHANGE_RATE_OBJECT, { + where: { currency, effective_date: { $lte: onOrBefore } }, + orderBy: [{ field: 'effective_date', order: 'desc' }], + limit: 1, + fields: ['rate', 'effective_date'], + context: { + ...(context ?? {}), + isSystem: true, + ...(organizationId !== undefined ? { tenantId: organizationId } : {}), + } as ExecutionContext, + }); + const row = rows[0] as { rate?: unknown; effective_date?: unknown } | undefined; + if (!row) return undefined; + const effective = row.effective_date; + return { + rate: row.rate, + effectiveDate: effective instanceof Date ? effective.toISOString().slice(0, 10) : (typeof effective === 'string' ? effective : undefined), + }; + }, + }); + } + + /** + * [ADR-0144 D5] The organization a written row belongs to, for its home + * projection: the row's own `organization_id`, else the writer's active + * organization, else the organization a system insert resolved — the + * assignment seam's reading, so both seams convert and route one row under + * one organization. + */ + private static homeAmountOrganizationOf( + row: Record | null | undefined, + context: ExecutionContext | undefined, + derived?: string | undefined, + ): string | undefined { + const own = row?.organization_id; + if (own !== undefined && own !== null && own !== '') return String(own); + if (context?.tenantId) return String(context.tenantId); + return derived ?? undefined; + } + + /** + * [ADR-0144 D5] The `validate()` preview's run of the home projection: the + * write's own computation over the preview's image (the insert row, or the + * stored row with the patch over it), answering nothing and writing + * nothing — it throws exactly the refusal the write would. + */ + private async previewHomeAmounts( + object: string, + schema: unknown, + mode: 'insert' | 'update', + row: Record, + image: Record, + context: ExecutionContext | undefined, + recordId: unknown, + ): Promise { + const plan = planHomeAmounts(schema); + const entries = mode === 'insert' ? plan : homeAmountsTouched(plan, row); + if (entries.length === 0) return; + await projectHomeAmounts(entries, image, this.homeAmountServing(context), { + object, + operation: mode, + organizationId: ObjectQL.homeAmountOrganizationOf(image, context), + recordId, + }); + } + + /** + * [ADR-0144 D5] Project a by-id update's home amounts onto its FINAL patch: + * only the declarations the patch touches ({@link homeAmountsTouched}), + * computed over the stored row with the patch laid over it. Shared by the + * write and its `validate()` preview, so a dry run refuses what the write + * refuses. + */ + private async projectHomeAmountsOntoPatch( + object: string, + plan: readonly HomeAmountPlanEntry[], + prior: Record | null | undefined, + patch: Record, + context: ExecutionContext | undefined, + recordId: unknown, + serving: HomeAmountServing = this.homeAmountServing(context), + ): Promise { + const touched = homeAmountsTouched(plan, patch); + if (touched.length === 0) return; + const image = { ...(prior ?? {}), ...patch }; + Object.assign(patch, await projectHomeAmounts(touched, image, serving, { + object, + operation: 'update', + organizationId: ObjectQL.homeAmountOrganizationOf(image, context), + recordId, + })); + } + /** * Locale + translation hooks handed to the validators so a rejected write is * reported in the caller's language (#3957). @@ -14530,6 +14668,7 @@ export class ObjectQL implements IObjectQLEngine { resolveOptionPermissions: (...a) => this.resolveOptionPermissions(...a), getSummaryDescriptors: (...a) => this.getSummaryDescriptors(...a), assertReferencesResolve: (...a) => this.assertReferencesResolve(...a), + previewHomeAmounts: (...a) => this.previewHomeAmounts(...a), }; return validate.call(host, object, data, options); } @@ -15585,6 +15724,35 @@ export class ObjectQL implements IObjectQLEngine { } } } + // [ADR-0144 D5] The write-time home projection — after every hook, + // strip and re-default, so the values it writes are the ones stored + // (a `readonly` strip never takes them, and a `defaultValue` on the + // home or rate field never stands), and before validation, which then + // judges them like any other stored value (the rate field's `scale` + // included). In every context: a system writer's value for either + // field is recomputed over too. A row it refuses (no home currency, no + // rate in force on the document date) is culled per row under partial + // mode, and nothing is written for it. Free for an object declaring no + // `homeAmount` (`planHomeAmounts` is one loop over the field names). + const homeAmountPlan = planHomeAmounts(schemaForValidation); + if (homeAmountPlan.length > 0) { + const homeServing = this.homeAmountServing(opCtx.context); + for (let i = 0; i < rows.length; i++) { + if (rowErrors[i] !== undefined) continue; + const row = rows[i] as Record | null | undefined; + if (!row || typeof row !== 'object') continue; + try { + Object.assign(row, await projectHomeAmounts(homeAmountPlan, row, homeServing, { + object, + operation: 'insert', + organizationId: ObjectQL.homeAmountOrganizationOf(row, opCtx.context, derivedOrganizationId), + })); + } catch (e) { + if (!partialMode) throw e; + rowErrors[i] = e; + } + } + } // [#4977] A `parent`-scoped `requiredWhen` ("once the header is Sent, // every line must carry a description") is a SERVER guarantee, and the // insert is where a line is first written empty — so unlike the @@ -17361,6 +17529,25 @@ export class ObjectQL implements IObjectQLEngine { applyStoredExpressionsToPatch(patch, computed!, priorRecord); } assertNoStrictDrops(); + // [ADR-0144 D5] The write-time home projection on the by-id + // path, over the stored row with the FINAL payload laid over it + // — after the hooks, both strips and the strict refusal, so its + // values are never stripped and a strict caller is told about + // its own payload first — and before the second validation pass, + // which judges what it wrote. Only when the payload touches an + // amount declaring `homeAmount`, its code, its document date or + // one of the two projected fields: an unrelated edit never + // re-reads a rate. A value a system or `preserveAudit` writer + // sent for the home or rate field is recomputed over. + { + const homeAmountPlan = planHomeAmounts(updateSchema); + if (homeAmountPlan.length > 0) { + await this.projectHomeAmountsOntoPatch( + object, homeAmountPlan, priorRecord, hookContext.input.data as Record, + opCtx.context, hookContext.input.id, + ); + } + } // [#21663] The payload is FINAL here (see the seam below), so // every readonly value on it is one the driver will store: a // system writer's (the strip above never ran for it), a hook's, @@ -17700,6 +17887,35 @@ export class ObjectQL implements IObjectQLEngine { } } assertNoStrictDrops(); + // [ADR-0144 D5] The write-time home projection on the predicate + // path, at the by-id branch's point and for its reasons. One + // patch reaches every matched row, so each matched row (the D7 + // read — `readPriorRows` is a memo) is projected with the patch + // laid over it, and {@link settleBulkHomeAmounts} keeps a + // projected value only when it is the same on every row; a patch + // that would convert to different values is refused before the + // statement. A refusal of any matched row (no home currency, no + // rate on its document date) refuses the whole update. + { + const homeAmountPlan = planHomeAmounts(updateSchema); + const homePatch = hookContext.input.data as Record; + const touched = homeAmountsTouched(homeAmountPlan, homePatch); + if (touched.length > 0) { + const matched = ((await readPriorRows()) ?? []).filter((row) => row != null && typeof row === 'object'); + const homeServing = this.homeAmountServing(opCtx.context); + const projected: Array> = []; + for (const row of matched) { + const image = { ...row, ...homePatch }; + projected.push(await projectHomeAmounts(touched, image, homeServing, { + object, + operation: 'update', + organizationId: ObjectQL.homeAmountOrganizationOf(image, opCtx.context), + recordId: row.id, + })); + } + hookContext.input.data = settleBulkHomeAmounts(touched, homePatch, projected, object) as any; + } + } // [#21663] The predicate-path twin of the by-id second pass, at the // same point and for the same reason: the readonly values left on // the final payload are stored, so their SHAPE is judged — before diff --git a/packages/objectql/src/home-amount-projection.ts b/packages/objectql/src/home-amount-projection.ts index 74cf0676744..13da222cdbc 100644 --- a/packages/objectql/src/home-amount-projection.ts +++ b/packages/objectql/src/home-amount-projection.ts @@ -451,3 +451,45 @@ export async function projectHomeAmounts( } return out; } + +/** + * The one patch a PREDICATE update can store for the projected fields of the + * entries it touches, judged over every matched row's projection. `updateMany` + * writes ONE patch to all of them, so a projected field is set only when it + * comes out the same on every matched row (the payload moved every row to one + * currency, amount and date, say). A patch that would project different + * values on different rows cannot be one statement, and is refused before + * anything is written: update those rows by id, where each is projected from + * its own stored row. A caller's value for a projected field never stands + * either way — the stored-expression rule for a predicate update, applied to + * the home projection. + */ +export function settleBulkHomeAmounts( + touched: readonly HomeAmountPlanEntry[], + patch: Record, + projected: ReadonlyArray>, + object: string, +): Record { + const out: Record = { ...patch }; + const refused: Array<{ field: string; code: 'invalid_value'; message: string }> = []; + for (const entry of touched) { + for (const name of [entry.homeField, entry.rateField]) { + delete out[name]; + if (projected.length === 0) continue; + const first = projected[0]![name]; + if (projected.every((values) => Object.is(values[name], first))) { + if (first !== undefined) out[name] = first; + continue; + } + refused.push({ + field: name, + code: 'invalid_value', + message: `'${name}' on '${object}' is the engine's conversion of '${entry.amountField}' into the home currency, ` + + 'and this update moves its inputs on rows where it then converts to different values, which one update ' + + 'of all the matched rows cannot store; update those rows by id, so each is converted from its own row', + }); + } + } + if (refused.length > 0) throw new ValidationError(refused); + return out; +} diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 1e264be2d2d..e1a24163c47 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -1340,6 +1340,30 @@ export class ObjectQLPlugin implements Plugin { error: e.message, }); } + + // [ADR-0144 D4, Open item 4] The home currency the write-time home + // projection converts into: the organization's `localization.currency` + // setting. The settings service is resolved when a write asks, not + // here, so plugin order cannot leave the projection without it. No + // settings service, or no `localization` manifest registered, is an + // unset home currency — refused, never defaulted; a read that FAILS is + // the write's failure, not an unset setting. + this.ql.setHomeCurrencyResolver(async (organizationId) => { + let settings: { get?: (ns: string, key: string, c?: Record) => Promise<{ value?: unknown } | undefined> } | undefined; + try { + settings = ctx.getService('settings') as typeof settings; + } catch { + return undefined; + } + if (!settings || typeof settings.get !== 'function') return undefined; + try { + const resolved = await settings.get('localization', 'currency', organizationId !== undefined ? { tenantId: organizationId } : {}); + return typeof resolved?.value === 'string' ? resolved.value : undefined; + } catch (e: any) { + if (e?.code === 'SETTINGS_UNKNOWN_NAMESPACE' || e?.code === 'SETTINGS_UNKNOWN_KEY') return undefined; + throw e; + } + }); } // Initialize drivers (calls driver.connect() which sets up persistence) From 2a87bd5d7f3c29416ceb01782e5211fc629ba3e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:12:19 +0000 Subject: [PATCH 09/18] wip(objectql): home projection tests (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude --- .../src/engine-home-amount-projection.test.ts | 366 ++++++++++++++++++ .../src/home-amount-projection.test.ts | 243 ++++++++++++ 2 files changed, 609 insertions(+) create mode 100644 packages/objectql/src/engine-home-amount-projection.test.ts create mode 100644 packages/objectql/src/home-amount-projection.test.ts diff --git a/packages/objectql/src/engine-home-amount-projection.test.ts b/packages/objectql/src/engine-home-amount-projection.test.ts new file mode 100644 index 00000000000..60fa37a2b8b --- /dev/null +++ b/packages/objectql/src/engine-home-amount-projection.test.ts @@ -0,0 +1,366 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0144 D5] The write-time home projection through the engine's write +// seams, on the in-memory face: a driver double whose `find` is the engine's +// own in-memory reading of a driver query (`applyDriverQueryInMemory`: where, +// orderBy, limit, fields), so the rate row the projection picks is chosen by +// the same `effective_date <= date ORDER BY effective_date DESC LIMIT 1` a +// SQL driver runs. ⛔ No driver-memory arm: its test consumers are a closed +// census (`check:driver-memory-census`). The same pins on SQLite and Postgres +// are packages/qa/dogfood's `test/home-amount-projection.test.ts`. +// +// Pinned here, per seam (insert, insertMany, by-id update, predicate update, +// the validate() preview): +// +// - the stored home amount is amount ÷ rate, rounded to the home minor units, +// stored with the rate used; the same currency as home is rate 1, read-free; +// - a missing rate refuses ERR_EXCHANGE_RATE_MISSING and an unset home +// currency ERR_HOME_CURRENCY_UNSET, and nothing is written; +// - the two projected fields: a caller's value is stripped (`readonly`) and +// recomputed over, a system or `preserveAudit` writer's value is recomputed +// over, a `defaultValue` never stands, a rate field `scale` below the +// rate's decimals refuses `max_scale` (never rounded); +// - an update projects when it moves the amount, its code or its document +// date, and never otherwise (a corrected rate reaches stored rows only +// through a recompute). + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { EngineUpdateOptions } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; +import { applyDriverQueryInMemory } from './driver-query-in-memory.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 finds: Record = {}; + let n = 0; + const select = (object: string, ast: any) => + applyDriverQueryInMemory(Array.from(storeFor(object).values()), { + where: ast?.where, orderBy: ast?.orderBy, limit: ast?.limit, offset: ast?.offset, + }); + 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) { + finds[object] = (finds[object] ?? 0) + 1; + return select(object, ast).map((r) => ({ ...r })); + }, + async findOne(object: string, ast: any) { + const [hit] = select(object, { ...ast, limit: 1 }); + return hit ? { ...hit } : 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); + const hits = select(object, ast); + for (const r of hits) s.set(r.id as string, { ...s.get(r.id as string), ...data }); + return hits.length; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async deleteMany() { return 0; }, + async count(object: string, ast: any) { return select(object, ast).length; }, + 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, finds }; +} + +/** The rate table's shape (`sys_exchange_rate`, owned by platform-objects; a stand-in here). */ +const RATE_OBJECT = { + name: 'sys_exchange_rate', + fields: { + currency: { type: 'text', valueDomain: 'iso_4217_currency' }, + rate: { type: 'number', scale: 12, precision: 24 }, + effective_date: { type: 'date' }, + }, +}; + +const HOME_AMOUNT = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }; +const invoice = (over: { home?: Record; rate?: Record } = {}) => ({ + name: 'fx_invoice', + fields: { + title: { type: 'text' }, + currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, + invoice_date: { type: 'date' }, + amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME_AMOUNT } }, + amount_home: { type: 'currency', readonly: true, ...over.home }, + amount_rate: { type: 'number', readonly: true, ...over.rate }, + }, +}); + +type Thrown = (Error & { code?: string; status?: number; field?: string; fields?: Array<{ field: string; code: string }> }) | null; +const refusalOf = async (p: Promise): Promise => p.then(() => null, (e) => e as Thrown); + +const SYS = { context: { isSystem: true } } as const; +const BATCH: EngineUpdateOptions = { where: { title: 'batch' }, multi: true }; + +async function boot(object = invoice(), home: string | undefined = 'CNY') { + const engine = new ObjectQL(); + const d = makeDriver(); + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject(RATE_OBJECT as any); + engine.registry.registerObject(object as any); + if (home !== undefined) engine.setHomeCurrencyResolver(async () => home); + const rate = (currency: string, effective_date: string, value: number) => + d.storeFor('sys_exchange_rate').set(`${currency}_${effective_date}`, { id: `${currency}_${effective_date}`, currency, effective_date, rate: value }); + return { engine, ...d, rate }; +} + +describe('insert: the home amount is the amount divided by the rate in force on the document date', () => { + let t: Awaited>; + beforeEach(async () => { + t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + t.rate('USD', '2026-04-01', 0.15); + t.rate('EUR', '2026-01-01', 0.128); + }); + + it('USD 10,000 at 0.1389 into CNY stores ¥71,994.24 and the rate 0.1389', async () => { + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + expect(row).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + }); + + it('a batch converts each row at its own currency and date, reading each (currency, date) rate once', async () => { + const before = t.finds.sys_exchange_rate ?? 0; + await t.engine.insert('fx_invoice', [ + { title: 'a', currency_code: 'USD', invoice_date: '2026-03-15', amount: 100 }, + { title: 'b', currency_code: 'USD', invoice_date: '2026-03-15', amount: 200 }, + { title: 'c', currency_code: 'USD', invoice_date: '2026-04-02', amount: 150 }, + { title: 'd', currency_code: 'EUR', invoice_date: '2026-03-15', amount: 64 }, + ]); + const byTitle = Object.fromEntries([...t.storeFor('fx_invoice').values()].map((r) => [r.title, [r.amount_home, r.amount_rate]])); + expect(byTitle).toEqual({ a: [719.94, 0.1389], b: [1439.88, 0.1389], c: [1000, 0.15], d: [500, 0.128] }); + expect((t.finds.sys_exchange_rate ?? 0) - before).toBe(3); + }); + + it('the same currency as home is rate 1 and reads no rate', async () => { + const before = t.finds.sys_exchange_rate ?? 0; + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'CNY', amount: 12.345 }); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 12.35, amount_rate: 1 }); + expect(t.finds.sys_exchange_rate ?? 0).toBe(before); + }); + + it('a blank amount stores a blank home amount and rate', async () => { + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15' }); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: null, amount_rate: null }); + }); + + it('no rate on or before the document date refuses ERR_EXCHANGE_RATE_MISSING, and nothing is written', async () => { + const err = await refusalOf(t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2025-12-31', amount: 1 })); + expect({ code: err?.code, status: err?.status, field: err?.field }).toEqual({ code: 'ERR_EXCHANGE_RATE_MISSING', status: 400, field: 'amount' }); + expect(err?.message).toContain('no exchange rate for USD has an effective date on or before 2025-12-31'); + const noRow = await refusalOf(t.engine.insert('fx_invoice', { title: 'i2', currency_code: 'JPY', invoice_date: '2026-03-15', amount: 1 })); + expect(noRow?.code).toBe('ERR_EXCHANGE_RATE_MISSING'); + expect(t.storeFor('fx_invoice').size).toBe(0); + }); + + it('a foreign amount with a blank document date is refused on the date', async () => { + const err = await refusalOf(t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', amount: 1 })); + expect(err?.code).toBe('VALIDATION_FAILED'); + expect(err?.fields?.map((f) => [f.field, f.code])).toEqual([['invoice_date', 'required']]); + expect(t.storeFor('fx_invoice').size).toBe(0); + }); + + it('a blank code is the currency-code refusal, not a rate lookup', async () => { + const err = await refusalOf(t.engine.insert('fx_invoice', { title: 'i1', invoice_date: '2026-03-15', amount: 1 })); + expect(err?.code).toBe('ERR_CURRENCY_CODE_REQUIRED'); + }); + + it('insertMany culls a refused row per row and stores the rest', async () => { + const outcomes = await t.engine.insertMany('fx_invoice', [ + { title: 'ok', currency_code: 'USD', invoice_date: '2026-03-15', amount: 100 }, + { title: 'no-rate', currency_code: 'JPY', invoice_date: '2026-03-15', amount: 100 }, + ]); + expect(outcomes.map((o: any) => [o.ok, o.error?.code])).toEqual([[true, undefined], [false, 'ERR_EXCHANGE_RATE_MISSING']]); + expect([...t.storeFor('fx_invoice').values()].map((r) => r.title)).toEqual(['ok']); + }); +}); + +describe('the home currency is the organization\'s setting, never a default', () => { + it('no resolver (no settings service) refuses ERR_HOME_CURRENCY_UNSET', async () => { + const t = await boot(invoice(), undefined); + const err = await refusalOf(t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 })); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'ERR_HOME_CURRENCY_UNSET', status: 400 }); + expect(err?.message).toContain('localization.currency'); + expect(t.storeFor('fx_invoice').size).toBe(0); + }); + + it('the resolver is asked for the row\'s organization', async () => { + const t = await boot(invoice(), undefined); + const asked: Array = []; + t.engine.setHomeCurrencyResolver(async (org) => { asked.push(org); return 'CNY'; }); + await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'CNY', amount: 1 }, { context: { isSystem: true, tenantId: 'org_a' } } as any); + expect(asked).toEqual(['org_a']); + }); +}); + +describe('only the engine writes the home and rate fields', () => { + it('a caller\'s value is stripped as readonly and the projection is stored', async () => { + const t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + const events: any[] = []; + const row = await t.engine.insert( + 'fx_invoice', + { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000, amount_home: 1, amount_rate: 2 }, + { onFieldsDropped: (e: any) => events.push(e) } as any, + ); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + expect(events.map((e) => [e.reason, e.fields])).toEqual([['readonly', ['amount_home', 'amount_rate']]]); + const strict = await refusalOf(t.engine.insert( + 'fx_invoice', { title: 'i2', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1, amount_home: 1 }, { strictReadonlyWrites: true } as any, + )); + expect(strict?.code).toBe('ERR_READONLY_FIELD_REJECTED'); + }); + + it('a system writer\'s value is recomputed over, on insert and on update', async () => { + const t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000, amount_home: 1, amount_rate: 2 }, SYS); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + await t.engine.update('fx_invoice', { id: row.id, amount_home: 5 }, SYS); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + }); + + it('a preserveAudit (historical import) update of the home field is recomputed over, not kept', async () => { + const t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }); + await t.engine.update('fx_invoice', { id: row.id, amount_home: 99999, amount_rate: 0.5 }, { context: { preserveAudit: true } } as any); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + }); + + it('a defaultValue on the home or rate field never stands', async () => { + const t = await boot(invoice({ home: { defaultValue: 0 }, rate: { defaultValue: 1 } })); + t.rate('USD', '2026-01-01', 0.1389); + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + const blank = await t.engine.insert('fx_invoice', { title: 'i2', currency_code: 'USD', invoice_date: '2026-03-15' }); + expect(t.storeFor('fx_invoice').get(blank.id)).toMatchObject({ amount_home: null, amount_rate: null }); + }); + + it('a rate field whose scale keeps fewer decimals than the rate refuses max_scale — the rate is never rounded', async () => { + const t = await boot(invoice({ rate: { scale: 4 } })); + t.rate('USD', '2026-01-01', 0.1389); + t.rate('USD', '2026-02-01', 0.138912); + const fits = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-01-15', amount: 10000 }); + expect(t.storeFor('fx_invoice').get(fits.id)).toMatchObject({ amount_rate: 0.1389 }); + const err = await refusalOf(t.engine.insert('fx_invoice', { title: 'i2', currency_code: 'USD', invoice_date: '2026-02-15', amount: 10000 })); + expect(err?.code).toBe('VALIDATION_FAILED'); + expect(err?.fields?.map((f) => [f.field, f.code])).toEqual([['amount_rate', 'max_scale']]); + expect(t.storeFor('fx_invoice').size).toBe(1); + }); +}); + +describe('update: the projection follows the amount, its code and its document date', () => { + let t: Awaited>; + let id: string; + beforeEach(async () => { + t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + t.rate('USD', '2026-04-01', 0.15); + t.rate('EUR', '2026-01-01', 0.128); + id = (await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 })).id; + }); + const stored = () => t.storeFor('fx_invoice').get(id); + + it('an amount change converts again at the same rate', async () => { + await t.engine.update('fx_invoice', { id, amount: 1389 }); + expect(stored()).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); + }); + + it('a code change converts at the new currency\'s rate', async () => { + await t.engine.update('fx_invoice', { id, currency_code: 'EUR' }); + expect(stored()).toMatchObject({ amount_home: 78125, amount_rate: 0.128 }); + await t.engine.update('fx_invoice', { id, currency_code: 'CNY' }); + expect(stored()).toMatchObject({ amount_home: 10000, amount_rate: 1 }); + }); + + it('a date change picks the rate in force on the new date', async () => { + await t.engine.update('fx_invoice', { id, invoice_date: '2026-04-01' }); + expect(stored()).toMatchObject({ amount_home: 66666.67, amount_rate: 0.15 }); + }); + + it('clearing the amount clears the home amount and rate', async () => { + await t.engine.update('fx_invoice', { id, amount: null }); + expect(stored()).toMatchObject({ amount_home: null, amount_rate: null }); + }); + + it('an unrelated edit after a rate correction leaves the stored conversion alone', async () => { + t.rate('USD', '2026-01-01', 0.2); + const before = t.finds.sys_exchange_rate ?? 0; + await t.engine.update('fx_invoice', { id, title: 'renamed' }); + expect(stored()).toMatchObject({ title: 'renamed', amount_home: 71994.24, amount_rate: 0.1389 }); + expect(t.finds.sys_exchange_rate ?? 0).toBe(before); + }); + + it('a date change with no rate in force refuses, and the stored row is untouched', async () => { + const err = await refusalOf(t.engine.update('fx_invoice', { id, invoice_date: '2025-06-30' })); + expect(err?.code).toBe('ERR_EXCHANGE_RATE_MISSING'); + expect(err?.message).toContain(`(record ${id})`); + expect(stored()).toMatchObject({ invoice_date: '2026-03-15', amount_home: 71994.24 }); + }); +}); + +describe('predicate update: one patch for every matched row', () => { + let t: Awaited>; + beforeEach(async () => { + t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + t.rate('EUR', '2026-01-01', 0.128); + await t.engine.insert('fx_invoice', { title: 'batch', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }); + await t.engine.insert('fx_invoice', { title: 'batch', currency_code: 'USD', invoice_date: '2026-03-15', amount: 2000 }); + }); + + it('a patch that converts every row to one value stores it', async () => { + await t.engine.update('fx_invoice', { amount: 128, currency_code: 'EUR' }, BATCH); + expect([...t.storeFor('fx_invoice').values()].map((r) => [r.amount_home, r.amount_rate])).toEqual([[1000, 0.128], [1000, 0.128]]); + }); + + it('a patch that converts rows to different values is refused before anything is written', async () => { + const err = await refusalOf(t.engine.update('fx_invoice', { currency_code: 'EUR' }, BATCH)); + expect(err?.code).toBe('VALIDATION_FAILED'); + expect(err?.message).toContain('update those rows by id'); + expect([...t.storeFor('fx_invoice').values()].map((r) => r.currency_code)).toEqual(['USD', 'USD']); + }); +}); + +describe('validate(): the preview refuses what the write refuses', () => { + it('insert and by-id update previews answer the projection\'s refusal as the row\'s finding', async () => { + const t = await boot(); + t.rate('USD', '2026-01-01', 0.1389); + const preview = await t.engine.validate('fx_invoice', [ + { title: 'p1', currency_code: 'JPY', invoice_date: '2026-03-15', amount: 1 }, + { title: 'p2', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 }, + ]); + expect(preview.results?.map((r) => r.valid)).toEqual([false, true]); + expect(preview.results?.[0]?.errors).toEqual([ + { field: 'amount', code: 'ERR_EXCHANGE_RATE_MISSING', message: expect.stringContaining('no exchange rate for JPY') }, + ]); + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 }); + const update = await t.engine.validate('fx_invoice', { id: row.id, invoice_date: '2025-01-01' }, { mode: 'update' }); + expect(update.results?.[0]?.errors?.map((e) => e.code)).toEqual(['ERR_EXCHANGE_RATE_MISSING']); + expect((await t.engine.validate('fx_invoice', { id: row.id, title: 'x' }, { mode: 'update' })).valid).toBe(true); + }); +}); diff --git a/packages/objectql/src/home-amount-projection.test.ts b/packages/objectql/src/home-amount-projection.test.ts new file mode 100644 index 00000000000..72884948179 --- /dev/null +++ b/packages/objectql/src/home-amount-projection.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0144 D5, D7 parameters 3 and 4, Open items 2, 3, 4 and 6] The home +// projection's own arithmetic and verdicts, with no engine: the exact decimal +// division, the one half-away-from-zero rounding to the home currency's minor +// units, the document-date reading, and what one projection writes or +// refuses over a served home currency and rate. The engine's three write +// seams are `engine-home-amount-projection.test.ts`; the same pins on SQLite +// and Postgres are packages/qa/dogfood's `test/home-amount-projection.test.ts`. + +import { describe, it, expect } from 'vitest'; +import { + documentDateOf, + divideToPlaces, + formatScaledDecimal, + homeAmountsTouched, + memoizeHomeAmountServing, + planHomeAmounts, + projectHomeAmounts, + settleBulkHomeAmounts, + toScaledDecimal, + ExchangeRateMissingError, + HomeCurrencyUnsetError, + type HomeAmountServing, +} from './home-amount-projection.js'; +import { ValidationError } from './validation/record-validator.js'; + +const HOME_AMOUNT = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }; +const SCHEMA = { + fields: { + currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, + invoice_date: { type: 'date' }, + amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME_AMOUNT } }, + amount_home: { type: 'currency', readonly: true }, + amount_rate: { type: 'number', readonly: true }, + plain: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }, + }, +}; +const PLAN = planHomeAmounts(SCHEMA); + +/** A served rate table: `rates[currency]` is a list of `[effective_date, rate]`; reads are counted. */ +function serving(home: string | undefined, rates: Record> = {}) { + const reads = { home: 0, rate: 0 }; + const s: HomeAmountServing = { + async homeCurrency() { + reads.home += 1; + return home; + }, + async rateOn(_org, currency, onOrBefore) { + reads.rate += 1; + const hit = (rates[currency] ?? []) + .filter(([d]) => d <= onOrBefore) + .sort(([a], [b]) => (a < b ? 1 : -1))[0]; + return hit ? { rate: hit[1], effectiveDate: hit[0] } : undefined; + }, + }; + return { s, reads }; +} + +const write = { object: 'fx_invoice', operation: 'insert' as const, organizationId: 'org_a' }; + +describe('the exact decimal arithmetic', () => { + it('reads a number by its shortest spelling and a string as written, exponents included', () => { + expect(toScaledDecimal(0.1389)).toEqual({ digits: 1389n, scale: 4 }); + expect(toScaledDecimal('7.123456789012')).toEqual({ digits: 7123456789012n, scale: 12 }); + expect(toScaledDecimal(-12.5)).toEqual({ digits: -125n, scale: 1 }); + expect(toScaledDecimal(1e21)).toEqual({ digits: 10n ** 21n, scale: 0 }); + expect(toScaledDecimal(1e-7)).toEqual({ digits: 1n, scale: 7 }); + expect(toScaledDecimal('.5')).toEqual({ digits: 5n, scale: 1 }); + for (const bad of ['', '.', 'abc', '1,000', NaN, Infinity, null, undefined, {}]) { + expect(toScaledDecimal(bad), String(bad)).toBeUndefined(); + } + }); + + it('divides by the rate (parameter 3) and rounds once, half away from zero', () => { + const div = (a: number | string, r: number | string, places: number) => + formatScaledDecimal(divideToPlaces(toScaledDecimal(a)!, toScaledDecimal(r)!, places)); + // The card's two-minute check: USD 10,000 at 0.1389 is ¥71,994.24 (71994.2404…). + expect(div(10000, 0.1389, 2)).toBe('71994.24'); + // Exactly half rounds away from zero, on both signs. + expect(div('0.125', 1, 2)).toBe('0.13'); + expect(div('-0.125', 1, 2)).toBe('-0.13'); + expect(div('0.124999', 1, 2)).toBe('0.12'); + // A float division would answer 0.30000000000000004 for 0.1 + 0.2 style inputs; exact here. + expect(div('0.3', '0.1', 2)).toBe('3.00'); + // Three minor-unit classes: JPY 0, USD 2, KWD 3. + expect(div(100, 0.0065, 0)).toBe('15385'); // 15384.615… + expect(div(100, 1.0857, 2)).toBe('92.11'); // 92.106… + expect(div(100, 3.2468, 3)).toBe('30.800'); // 30.79955… + }); +}); + +describe('the document date', () => { + it('a date is its own day; a datetime instant is its UTC day', () => { + expect(documentDateOf('2026-03-15')).toBe('2026-03-15'); + expect(documentDateOf('2026-03-31T23:30:00-05:00')).toBe('2026-04-01'); + expect(documentDateOf('2026-03-31T23:30:00Z')).toBe('2026-03-31'); + expect(documentDateOf(new Date('2026-03-15T08:00:00Z'))).toBe('2026-03-15'); + expect(documentDateOf('not a date')).toBeUndefined(); + expect(documentDateOf(20260315)).toBeUndefined(); + }); +}); + +describe('the plan', () => { + it('reads only a record-mode amount declaring homeAmount', () => { + expect(PLAN).toEqual([{ + amountField: 'amount', codeField: 'currency_code', homeField: 'amount_home', rateDateField: 'invoice_date', rateField: 'amount_rate', + }]); + expect(planHomeAmounts({ fields: { a: { type: 'currency' } } })).toEqual([]); + expect(planHomeAmounts(undefined)).toEqual([]); + }); + + it('an update projects only when it carries an input or a projected field', () => { + for (const key of ['amount', 'currency_code', 'invoice_date', 'amount_home', 'amount_rate']) { + expect(homeAmountsTouched(PLAN, { [key]: 1 }), key).toEqual(PLAN); + } + expect(homeAmountsTouched(PLAN, { plain: 1, name: 'x' })).toEqual([]); + }); +}); + +describe('one projection', () => { + it('a foreign amount is divided by the rate in force on the document date, and the rate is stored as read', async () => { + const { s } = serving('CNY', { USD: [['2026-01-01', 0.1389]] }); + const out = await projectHomeAmounts(PLAN, { amount: 10000, currency_code: 'USD', invoice_date: '2026-03-15' }, s, write); + expect(out).toEqual({ amount_home: 71994.24, amount_rate: 0.1389 }); + }); + + it('the rate row is the latest one on or before the document date — never a later one', async () => { + const { s } = serving('USD', { EUR: [['2026-01-01', 0.8], ['2026-03-15', 0.9], ['2026-03-16', 0.5]] }); + const at = (invoice_date: string) => projectHomeAmounts(PLAN, { amount: 90, currency_code: 'EUR', invoice_date }, s, write); + expect(await at('2026-03-15')).toEqual({ amount_home: 100, amount_rate: 0.9 }); // on the date + expect(await at('2026-03-14')).toEqual({ amount_home: 112.5, amount_rate: 0.8 }); // the earlier row + expect(await at('2026-03-16')).toEqual({ amount_home: 180, amount_rate: 0.5 }); + }); + + it('a date before every rate row refuses ERR_EXCHANGE_RATE_MISSING, naming the currency and the date', async () => { + const { s } = serving('USD', { EUR: [['2026-03-16', 0.9]] }); + const err = await projectHomeAmounts(PLAN, { amount: 90, currency_code: 'EUR', invoice_date: '2026-03-15' }, s, write) + .then(() => null, (e) => e); + expect(err).toBeInstanceOf(ExchangeRateMissingError); + expect({ code: err.code, status: err.status, field: err.field, currency: err.currency, date: err.date, homeCurrency: err.homeCurrency }) + .toEqual({ code: 'ERR_EXCHANGE_RATE_MISSING', status: 400, field: 'amount', currency: 'EUR', date: '2026-03-15', homeCurrency: 'USD' }); + expect(err.message).toContain('no exchange rate for EUR has an effective date on or before 2026-03-15'); + expect(err.message).not.toMatch(/#\d/); + }); + + it('a stored rate that is not a positive number counts as missing', async () => { + for (const rate of [0, -1, null, 'abc']) { + const { s } = serving('USD', { EUR: [['2026-01-01', rate]] }); + await expect(projectHomeAmounts(PLAN, { amount: 1, currency_code: 'EUR', invoice_date: '2026-03-15' }, s, write), String(rate)) + .rejects.toBeInstanceOf(ExchangeRateMissingError); + } + }); + + it('the same currency as home converts at 1, with no rate read, rounded to the home minor units', async () => { + const { s, reads } = serving('USD'); + expect(await projectHomeAmounts(PLAN, { amount: 10.005, currency_code: 'USD' }, s, write)) + .toEqual({ amount_home: 10.01, amount_rate: 1 }); + expect(reads.rate).toBe(0); + }); + + it('three minor-unit classes: JPY 0, CNY 2, KWD 3', async () => { + const rates = { EUR: [['2026-01-01', 0.0065], ['2026-01-02', 0.1289], ['2026-01-03', 3.0103]] as Array<[string, unknown]> }; + const at = async (home: string, date: string) => + projectHomeAmounts(PLAN, { amount: 100, currency_code: 'EUR', invoice_date: date }, serving(home, rates).s, write); + expect(await at('JPY', '2026-01-01')).toEqual({ amount_home: 15385, amount_rate: 0.0065 }); // 15384.615… + expect(await at('CNY', '2026-01-02')).toEqual({ amount_home: 775.8, amount_rate: 0.1289 }); // 775.79519… + expect(await at('KWD', '2026-01-03')).toEqual({ amount_home: 33.219, amount_rate: 3.0103 }); // 33.2192… + }); + + it('a blank amount stores a blank home amount and rate, and reads nothing', async () => { + const { s, reads } = serving(undefined); + expect(await projectHomeAmounts(PLAN, { amount: null, currency_code: 'EUR' }, s, write)) + .toEqual({ amount_home: null, amount_rate: null }); + expect(reads).toEqual({ home: 0, rate: 0 }); + }); + + it('no home currency refuses ERR_HOME_CURRENCY_UNSET, naming the setting — never a default', async () => { + for (const home of [undefined, '', 'XX']) { + const err = await projectHomeAmounts(PLAN, { amount: 1, currency_code: 'EUR', invoice_date: '2026-01-01' }, serving(home).s, write) + .then(() => null, (e) => e); + expect(err, String(home)).toBeInstanceOf(HomeCurrencyUnsetError); + expect({ code: err.code, status: err.status, field: err.field, setting: err.setting }) + .toEqual({ code: 'ERR_HOME_CURRENCY_UNSET', status: 400, field: 'amount', setting: 'localization.currency' }); + expect(err.message).toContain('localization.currency'); + } + }); + + it('a foreign amount with a blank document date is a required-field refusal on the date', async () => { + const err = await projectHomeAmounts(PLAN, { amount: 1, currency_code: 'EUR' }, serving('USD').s, write).then(() => null, (e) => e); + expect(err).toBeInstanceOf(ValidationError); + expect(err.fields.map((f: { field: string; code: string }) => ({ field: f.field, code: f.code }))) + .toEqual([{ field: 'invoice_date', code: 'required' }]); + }); + + it('leaves the entry to the check that refuses its input: blank or non-ISO code, unparsable amount or date', async () => { + const { s, reads } = serving('USD'); + for (const image of [ + { amount: 1, currency_code: '' }, + { amount: 1, currency_code: 'usd' }, + { amount: 1, currency_code: 'ZZZ' }, + { amount: 'lots', currency_code: 'EUR', invoice_date: '2026-01-01' }, + { amount: 1, currency_code: 'EUR', invoice_date: 'someday' }, + ]) { + expect(await projectHomeAmounts(PLAN, image, s, write), JSON.stringify(image)).toEqual({}); + } + expect(reads.rate).toBe(0); + }); + + it('a memoized serving reads each home currency and each (currency, date) rate once', async () => { + const { s, reads } = serving('USD', { EUR: [['2026-01-01', 0.9]] }); + const m = memoizeHomeAmountServing(s); + for (let i = 0; i < 5; i++) { + await projectHomeAmounts(PLAN, { amount: i + 1, currency_code: 'EUR', invoice_date: '2026-02-01' }, m, write); + } + expect(reads).toEqual({ home: 1, rate: 1 }); + }); +}); + +describe('the predicate update settlement', () => { + it('keeps a projected value that is the same on every matched row, and drops a caller value either way', () => { + expect(settleBulkHomeAmounts(PLAN, { currency_code: 'USD', amount_home: 5 }, [ + { amount_home: 10, amount_rate: 1 }, { amount_home: 10, amount_rate: 1 }, + ], 'fx_invoice')).toEqual({ currency_code: 'USD', amount_home: 10, amount_rate: 1 }); + expect(settleBulkHomeAmounts(PLAN, { amount_home: 5 }, [], 'fx_invoice')).toEqual({}); + }); + + it('refuses a patch that converts to different values on different rows', () => { + const err = (() => { + try { + settleBulkHomeAmounts(PLAN, { currency_code: 'USD' }, [ + { amount_home: 10, amount_rate: 1 }, { amount_home: 20, amount_rate: 1 }, + ], 'fx_invoice'); + } catch (e) { + return e as ValidationError; + } + return null; + })(); + expect(err).toBeInstanceOf(ValidationError); + expect(err!.fields.map((f) => ({ field: f.field, code: f.code }))).toEqual([{ field: 'amount_home', code: 'invalid_value' }]); + expect(err!.message).toContain('update those rows by id'); + }); +}); From 89c3c186c5ae13a4fdcf519ffc449bf79d4eb426 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:14:14 +0000 Subject: [PATCH 10/18] wip(objectql): home projection engine tests green on the in-memory face (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude --- .../src/engine-home-amount-projection.test.ts | 27 ++++++++++--------- .../objectql/src/home-amount-projection.ts | 9 ++++--- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/objectql/src/engine-home-amount-projection.test.ts b/packages/objectql/src/engine-home-amount-projection.test.ts index 60fa37a2b8b..da78bb1129c 100644 --- a/packages/objectql/src/engine-home-amount-projection.test.ts +++ b/packages/objectql/src/engine-home-amount-projection.test.ts @@ -114,14 +114,15 @@ const refusalOf = async (p: Promise): Promise => p.then(() => n const SYS = { context: { isSystem: true } } as const; const BATCH: EngineUpdateOptions = { where: { title: 'batch' }, multi: true }; -async function boot(object = invoice(), home: string | undefined = 'CNY') { +/** `home: null` composes no home-currency resolver at all (no settings service). */ +async function boot(object = invoice(), home: string | null = 'CNY') { const engine = new ObjectQL(); const d = makeDriver(); engine.registerDriver(d.driver, true); await engine.init(); engine.registry.registerObject(RATE_OBJECT as any); engine.registry.registerObject(object as any); - if (home !== undefined) engine.setHomeCurrencyResolver(async () => home); + if (home !== null) engine.setHomeCurrencyResolver(async () => home); const rate = (currency: string, effective_date: string, value: number) => d.storeFor('sys_exchange_rate').set(`${currency}_${effective_date}`, { id: `${currency}_${effective_date}`, currency, effective_date, rate: value }); return { engine, ...d, rate }; @@ -200,7 +201,7 @@ describe('insert: the home amount is the amount divided by the rate in force on describe('the home currency is the organization\'s setting, never a default', () => { it('no resolver (no settings service) refuses ERR_HOME_CURRENCY_UNSET', async () => { - const t = await boot(invoice(), undefined); + const t = await boot(invoice(), null); const err = await refusalOf(t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 })); expect({ code: err?.code, status: err?.status }).toEqual({ code: 'ERR_HOME_CURRENCY_UNSET', status: 400 }); expect(err?.message).toContain('localization.currency'); @@ -208,7 +209,7 @@ describe('the home currency is the organization\'s setting, never a default', () }); it('the resolver is asked for the row\'s organization', async () => { - const t = await boot(invoice(), undefined); + const t = await boot(invoice(), null); const asked: Array = []; t.engine.setHomeCurrencyResolver(async (org) => { asked.push(org); return 'CNY'; }); await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'CNY', amount: 1 }, { context: { isSystem: true, tenantId: 'org_a' } } as any); @@ -260,16 +261,16 @@ describe('only the engine writes the home and rate fields', () => { expect(t.storeFor('fx_invoice').get(blank.id)).toMatchObject({ amount_home: null, amount_rate: null }); }); - it('a rate field whose scale keeps fewer decimals than the rate refuses max_scale — the rate is never rounded', async () => { + it('a rate field declaring a scale below the rate\'s decimals stores the rate unrounded, and converts with all of it', async () => { + // `readonly` values are judged for SHAPE only (`ReadonlyValueScope`), so a + // declared `scale` is no write constraint on the rate field, and every + // numeric column is the same exact decimal: the stored rate is the rate + // row's own value, and the stored home amount reproduces from it. const t = await boot(invoice({ rate: { scale: 4 } })); - t.rate('USD', '2026-01-01', 0.1389); - t.rate('USD', '2026-02-01', 0.138912); - const fits = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-01-15', amount: 10000 }); - expect(t.storeFor('fx_invoice').get(fits.id)).toMatchObject({ amount_rate: 0.1389 }); - const err = await refusalOf(t.engine.insert('fx_invoice', { title: 'i2', currency_code: 'USD', invoice_date: '2026-02-15', amount: 10000 })); - expect(err?.code).toBe('VALIDATION_FAILED'); - expect(err?.fields?.map((f) => [f.field, f.code])).toEqual([['amount_rate', 'max_scale']]); - expect(t.storeFor('fx_invoice').size).toBe(1); + t.rate('USD', '2026-02-01', 0.138912345678); + const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', invoice_date: '2026-02-15', amount: 10000 }); + // 10000 ÷ 0.138912345678 = 71987.84…; at the rounded 0.1389 it would be 71994.24. + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 71987.84, amount_rate: 0.138912345678 }); }); }); diff --git a/packages/objectql/src/home-amount-projection.ts b/packages/objectql/src/home-amount-projection.ts index 13da222cdbc..08fcb883cca 100644 --- a/packages/objectql/src/home-amount-projection.ts +++ b/packages/objectql/src/home-amount-projection.ts @@ -35,10 +35,11 @@ * `CURRENCY_FRACTION_DIGITS` (Open item 3): JPY 0, USD 2, KWD 3. * - **The rate stored** is the rate row's own value, never rounded (D1: the * amount is stored "with the rate used"; a rounded rate would not reproduce - * the stored home amount). A rate field whose declared `scale` keeps fewer - * decimals than the rate carries refuses the write at the record - * validator's `max_scale` seam, as every number field does; the engine - * never rounds a value to fit a field. + * the stored home amount). The rate field is `readonly`, and a readonly + * value is judged for its shape only (`ReadonlyValueScope`), so a `scale` + * it declares below the rate's twelve decimals is no write constraint: the + * column (every numeric column is the same exact decimal) keeps every digit, + * and the declared scale reaches only the display. * * ## Who writes the two fields * From e62d038623693f866679a6bdfaf6c48215f3238e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:15:40 +0000 Subject: [PATCH 11/18] wip(dogfood): home projection rounding pins on better-sqlite3, sqlite-wasm and Postgres (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude --- .../test/home-amount-projection.test.ts | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 packages/qa/dogfood/test/home-amount-projection.test.ts diff --git a/packages/qa/dogfood/test/home-amount-projection.test.ts b/packages/qa/dogfood/test/home-amount-projection.test.ts new file mode 100644 index 00000000000..fa763bc4095 --- /dev/null +++ b/packages/qa/dogfood/test/home-amount-projection.test.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0144 D5, D7 parameters 3 and 4, Open items 2, 3 and 6] The write-time +// home projection's rounding pins on the real SQL drivers, with the platform's +// own `sys_exchange_rate` object: the rate row is chosen by each dialect's +// `effective_date <= ? ORDER BY effective_date DESC LIMIT 1`, read back +// through each dialect's exact-decimal column, and the home amount is stored +// and read back through each dialect's currency column. +// +// Cells: better-sqlite3 and sqlite-wasm always run; PostgreSQL runs where +// `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. The Postgres +// cell owns one schema, named from this file, created before and dropped +// after. The in-memory face of the same pins (and the strips, defaults, +// preview and predicate-update cells) is packages/objectql's +// `engine-home-amount-projection.test.ts`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { SysExchangeRate } from '@objectstack/platform-objects/system'; +import { installDefaultOrganization, DEFAULT_ORGANIZATION_ID } from './default-organization.testkit.js'; + +const LIVE_NAMESPACE = `os_lv_${basename(fileURLToPath(import.meta.url)).replace(/\.test\.ts$/, '').replace(/[^a-z0-9]+/gi, '_')}`; +const PG_URL = process.env.OS_TEST_POSTGRES_URL; + +async function adminRaw(config: Record, statements: string[]): Promise { + const admin = new SqlDriver(config as never); + try { + for (const statement of statements) await admin.execute(statement); + } finally { + await admin.disconnect(); + } +} + +interface Cell { + readonly id: string; + readonly env: string | null; + readonly url: string | undefined; + readonly make: () => Promise; + readonly teardown: () => Promise; +} + +const CELLS: readonly Cell[] = [ + { + id: 'driver-sql (better-sqlite3 :memory:)', + env: null, + url: 'sqlite', + make: async () => new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + teardown: async () => {}, + }, + { + id: 'driver-sqlite-wasm (:memory:)', + env: null, + url: 'sqlite-wasm', + make: async () => new SqliteWasmDriver({ filename: ':memory:' }), + teardown: async () => {}, + }, + { + id: 'driver-sql (pg)', + env: 'OS_TEST_POSTGRES_URL', + url: PG_URL, + make: async () => { + await adminRaw({ client: 'pg', connection: PG_URL }, [ + `drop schema if exists ${LIVE_NAMESPACE} cascade`, + `create schema ${LIVE_NAMESPACE}`, + ]); + return new SqlDriver({ client: 'pg', connection: PG_URL, searchPath: [LIVE_NAMESPACE] } as never); + }, + teardown: async () => { + await adminRaw({ client: 'pg', connection: PG_URL }, [`drop schema if exists ${LIVE_NAMESPACE} cascade`]); + }, + }, +]; + +const SYS = { context: { isSystem: true } }; + +const FX_INVOICE = { + name: 'fx_invoice', + label: 'FX invoice', + fields: { + name: { name: 'name', type: 'text' }, + currency_code: { name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' }, + invoice_date: { name: 'invoice_date', type: 'date' }, + amount: { + name: 'amount', + type: 'currency', + currencyConfig: { + currencyMode: 'record', + currencyField: 'currency_code', + homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }, + }, + }, + amount_home: { name: 'amount_home', type: 'currency', readonly: true }, + amount_rate: { name: 'amount_rate', type: 'number', readonly: true }, + }, +}; + +const appWith = (objects: unknown[]) => ({ + id: 'com.objectstack.dogfood.home-amount-projection', + name: 'Home amount projection', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects, +}); + +/** Each rate as `[currency, effective_date, rate]`; every currency's rate against the home currency of the test. */ +const RATES: ReadonlyArray<[string, string, number]> = [ + ['USD', '2026-01-01', 0.1389], + ['USD', '2026-04-01', 0.15], + ['USD', '2026-05-01', 7.123456789012], // twelve decimals, the column's scale + ['EUR', '2026-01-01', 0.0065], // read as JPY-home in the minor-unit cell + ['GBP', '2026-01-01', 1.0857], + ['CHF', '2026-01-01', 3.2468], +]; + +for (const cell of CELLS) { + describe.skipIf(!cell.url)( + `the write-time home projection — ${cell.id}${cell.url ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let driver: SqlDriver; + let engine: ObjectQL; + let home = 'CNY'; + + beforeAll(async () => { + driver = await cell.make(); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registerApp(appWith([SysExchangeRate, FX_INVOICE])); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + engine.setHomeCurrencyResolver(async () => home); + for (const [currency, effective_date, rate] of RATES) { + await engine.insert('sys_exchange_rate', { currency, effective_date, rate, source: 'manual' }, SYS); + } + }); + + afterAll(async () => { + await engine?.destroy(); + await cell.teardown(); + }); + + /** Insert one invoice and read it back from the store. */ + async function written(row: Record) { + const created = await engine.insert('fx_invoice', { name: 'inv', ...row }, SYS); + return read(created.id); + } + async function read(id: unknown) { + const back = await engine.findOne('fx_invoice', { where: { id }, context: SYS.context } as never) as Record; + return { home: Number(back.amount_home), rate: Number(back.amount_rate), org: back.organization_id }; + } + + it('the card\'s check: USD 10,000 at 0.1389 into CNY stores 71994.24 with the rate 0.1389', async () => { + home = 'CNY'; + expect(await written({ currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 })) + .toEqual({ home: 71994.24, rate: 0.1389, org: DEFAULT_ORGANIZATION_ID }); + }); + + it('three minor-unit classes round half away from zero: JPY 0, USD 2, KWD 3', async () => { + home = 'JPY'; + expect(await written({ currency_code: 'EUR', invoice_date: '2026-03-15', amount: 100 })).toMatchObject({ home: 15385, rate: 0.0065 }); // 15384.615… + home = 'USD'; + expect(await written({ currency_code: 'GBP', invoice_date: '2026-03-15', amount: 100 })).toMatchObject({ home: 92.11, rate: 1.0857 }); // 92.1064… + home = 'KWD'; + expect(await written({ currency_code: 'CHF', invoice_date: '2026-03-15', amount: 100 })).toMatchObject({ home: 30.8, rate: 3.2468 }); // 30.79955… + home = 'CNY'; + }); + + it('a twelve-decimal rate survives the column and divides exactly', async () => { + // 1000 ÷ 7.123456789012 = 140.381282… + expect(await written({ currency_code: 'USD', invoice_date: '2026-05-02', amount: 1000 })).toMatchObject({ home: 140.38, rate: 7.123456789012 }); + }); + + it('the same currency as home is rate 1, rounded to the home minor units', async () => { + home = 'CNY'; + expect(await written({ currency_code: 'CNY', amount: 12.345 })).toMatchObject({ home: 12.35, rate: 1 }); + }); + + it('the rate row is the latest on or before the document date; none ⇒ refused, nothing stored', async () => { + home = 'CNY'; + expect(await written({ currency_code: 'USD', invoice_date: '2026-03-31', amount: 150 })).toMatchObject({ home: 1079.91, rate: 0.1389 }); + expect(await written({ currency_code: 'USD', invoice_date: '2026-04-01', amount: 150 })).toMatchObject({ home: 1000, rate: 0.15 }); + const before = (await engine.find('fx_invoice', { context: SYS.context } as never)).length; + const err = await engine.insert('fx_invoice', { name: 'early', currency_code: 'USD', invoice_date: '2025-12-31', amount: 1 }, SYS) + .then(() => null, (e: { code?: string; status?: number; currency?: string; date?: string }) => e); + expect({ code: err?.code, status: err?.status, currency: err?.currency, date: err?.date }) + .toEqual({ code: 'ERR_EXCHANGE_RATE_MISSING', status: 400, currency: 'USD', date: '2025-12-31' }); + expect((await engine.find('fx_invoice', { context: SYS.context } as never)).length).toBe(before); + }); + + it('an update of the amount, the code or the date converts again; another edit does not', async () => { + home = 'CNY'; + const { id } = await engine.insert('fx_invoice', { name: 'u', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, SYS); + await engine.update('fx_invoice', { id, amount: 1389 }, SYS); + expect(await read(id)).toMatchObject({ home: 10000, rate: 0.1389 }); + await engine.update('fx_invoice', { id, invoice_date: '2026-04-15' }, SYS); + expect(await read(id)).toMatchObject({ home: 9260, rate: 0.15 }); + await engine.update('fx_invoice', { id, currency_code: 'CNY' }, SYS); + expect(await read(id)).toMatchObject({ home: 1389, rate: 1 }); + await engine.update('fx_invoice', { id, currency_code: 'USD', invoice_date: '2026-03-15' }, SYS); + expect(await read(id)).toMatchObject({ home: 10000, rate: 0.1389 }); + await engine.update('fx_invoice', { id, name: 'renamed' }, SYS); + expect(await read(id)).toMatchObject({ home: 10000, rate: 0.1389 }); + }); + }, + ); +} From 03cab4ab33a71b9362dad16f715dad7594c0c299 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:17:08 +0000 Subject: [PATCH 12/18] wip: write-path (import/REST) test; float-rounding pins (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude --- .../src/engine-home-amount-projection.test.ts | 3 + .../src/home-amount-projection.test.ts | 2 + .../src/home-amount-write-paths.test.ts | 188 ++++++++++++++++++ .../test/home-amount-projection.test.ts | 2 + 4 files changed, 195 insertions(+) create mode 100644 packages/objectql/src/home-amount-write-paths.test.ts diff --git a/packages/objectql/src/engine-home-amount-projection.test.ts b/packages/objectql/src/engine-home-amount-projection.test.ts index da78bb1129c..a92d8659aec 100644 --- a/packages/objectql/src/engine-home-amount-projection.test.ts +++ b/packages/objectql/src/engine-home-amount-projection.test.ts @@ -160,6 +160,9 @@ describe('insert: the home amount is the amount divided by the rate in force on const before = t.finds.sys_exchange_rate ?? 0; const row = await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'CNY', amount: 12.345 }); expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 12.35, amount_rate: 1 }); + // 1.005 is 1.00499999… as a double: a float rounding stores 1.00, the decimal one 1.01. + const half = await t.engine.insert('fx_invoice', { title: 'i2', currency_code: 'CNY', amount: 1.005 }); + expect(t.storeFor('fx_invoice').get(half.id)).toMatchObject({ amount_home: 1.01, amount_rate: 1 }); expect(t.finds.sys_exchange_rate ?? 0).toBe(before); }); diff --git a/packages/objectql/src/home-amount-projection.test.ts b/packages/objectql/src/home-amount-projection.test.ts index 72884948179..6ac64fca752 100644 --- a/packages/objectql/src/home-amount-projection.test.ts +++ b/packages/objectql/src/home-amount-projection.test.ts @@ -81,6 +81,8 @@ describe('the exact decimal arithmetic', () => { expect(div('0.125', 1, 2)).toBe('0.13'); expect(div('-0.125', 1, 2)).toBe('-0.13'); expect(div('0.124999', 1, 2)).toBe('0.12'); + // 1.005 has no exact binary double (1.00499999…), so a float `toFixed(2)` answers 1.00; the decimal is 1.01. + expect(div(1.005, 1, 2)).toBe('1.01'); // A float division would answer 0.30000000000000004 for 0.1 + 0.2 style inputs; exact here. expect(div('0.3', '0.1', 2)).toBe('3.00'); // Three minor-unit classes: JPY 0, USD 2, KWD 3. diff --git a/packages/objectql/src/home-amount-write-paths.test.ts b/packages/objectql/src/home-amount-write-paths.test.ts new file mode 100644 index 00000000000..d2cdcb41c40 --- /dev/null +++ b/packages/objectql/src/home-amount-write-paths.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0144 D5] Import and REST writes reach the home projection through the +// one engine path, on a booted kernel: `ObjectQLPlugin` with its settings +// bridge (the home currency is the `settings` service's +// `localization.currency`, asked for the row's organization), the real +// protocol, and the shared import runner. +// +// The reading this pins: the REST data routes write through the protocol's +// `createData` / `updateData` (`packages/rest/src/crud-routes.ts`), which call +// `engine.insert` / `engine.update`; an import (`POST …/import` and the +// import-job worker, both `runImport`) writes through the protocol's +// `insertManyData` (batched, `engine.insertMany` → `engine.insert`) or +// `createData` / `updateData` (per row). Every one of them lands in the same +// insert or update seam, so every one of them converts, and refuses, alike. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectKernel, runImport, type ImportProtocolLike } from '@objectstack/core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectQLPlugin } from './plugin.js'; +import { ObjectQL } from './engine.js'; +import { applyDriverQueryInMemory } from './driver-query-in-memory.js'; + +function makeStubDriver() { + 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 select = (o: string, ast: any) => + applyDriverQueryInMemory(Array.from(storeFor(o).values()), { where: ast?.where, orderBy: ast?.orderBy, limit: ast?.limit, offset: ast?.offset }); + let n = 0; + const driver: any = { + name: 'fx-store', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async syncSchema() {}, + async find(o: string, ast: any) { return select(o, ast).map((r) => ({ ...r })); }, + async findOne(o: string, ast: any) { const [r] = select(o, { ...ast, limit: 1 }); return r ? { ...r } : null; }, + async create(o: string, data: Record) { + n += 1; + const id = (data.id as string) ?? `rec_${n}`; + const row = { ...data, id }; + storeFor(o).set(id, row); + return { ...row }; + }, + async update(o: string, id: string, data: Record) { + const row = { ...storeFor(o).get(id), ...data, id }; + storeFor(o).set(id, row); + return { ...row }; + }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count(o: string, ast: any) { return select(o, ast).length; }, + }; + return { driver, storeFor }; +} + +const OBJECT = 'fx_invoice'; +const HOME_AMOUNT = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }; +const FIELDS = { + code: { name: 'code', type: 'text' }, + currency_code: { name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' }, + invoice_date: { name: 'invoice_date', type: 'date' }, + amount: { name: 'amount', type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME_AMOUNT } }, + amount_home: { name: 'amount_home', type: 'currency', readonly: true }, + amount_rate: { name: 'amount_rate', type: 'number', readonly: true }, +}; + +describe('import and REST writes convert through the same engine path', () => { + let kernel: ObjectKernel; + let objectql: ObjectQL; + let stub: ReturnType; + /** What the settings service was asked, and what it answers. */ + let settingsAsked: Array<{ ns: string; key: string; tenantId?: unknown }>; + let home: string | undefined; + + async function bootKernel(withSettings: boolean) { + settingsAsked = []; + home = 'CNY'; + kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + stub = makeStubDriver(); + await kernel.use({ + name: 'fx-store-plugin', type: 'driver', version: '1.0.0', + init: async (ctx: any) => { ctx.registerService('driver.fx-store', stub.driver); }, + } as any); + if (withSettings) { + await kernel.use({ + name: 'fake-settings', type: 'standard', version: '1.0.0', + init: async (ctx: any) => { + ctx.registerService('settings', { + async get(ns: string, key: string, c?: { tenantId?: unknown }) { + settingsAsked.push({ ns, key, tenantId: c?.tenantId }); + return { value: home }; + }, + }); + }, + } as any); + } + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + objectql = kernel.getService('objectql'); + objectql.registry.registerObject({ + name: 'sys_exchange_rate', datasource: 'fx-store', + fields: { + currency: { name: 'currency', type: 'text', valueDomain: 'iso_4217_currency' }, + rate: { name: 'rate', type: 'number', scale: 12, precision: 24 }, + effective_date: { name: 'effective_date', type: 'date' }, + }, + } as any, 'test', 'test'); + objectql.registry.registerObject({ name: OBJECT, label: 'FX invoice', datasource: 'fx-store', fields: FIELDS } as any, 'test', 'test'); + stub.storeFor('sys_exchange_rate').set('usd', { id: 'usd', currency: 'USD', effective_date: '2026-01-01', rate: 0.1389 }); + } + + afterEach(async () => { + if (kernel?.getState() === 'running') await kernel.shutdown(); + }); + + const storedByCode = (code: string) => { + const hits = Array.from(stub.storeFor(OBJECT).values()).filter((r) => r.code === code); + expect(hits.length, `one stored row for ${code}`).toBe(1); + return hits[0]!; + }; + const protocol = () => new ObjectStackProtocolImplementation(objectql as never) as unknown as ImportProtocolLike & { + createData(a: unknown): Promise; updateData(a: unknown): Promise; + }; + const perRow = (): ImportProtocolLike => { + const impl = protocol(); + return { findData: (a) => impl.findData(a), createData: (a) => impl.createData(a), updateData: (a) => impl.updateData(a) }; + }; + const metaMap = new Map(Object.entries(FIELDS)); + const importRows = (p: ImportProtocolLike, rows: Array>, over: Record = {}) => runImport({ + p, objectName: OBJECT, metaMap, writeMode: 'insert', matchFields: ['code'], dryRun: false, runAutomations: false, + trimWhitespace: true, skipBlankMatchKey: false, context: { userId: 'usr_importer', tenantId: 'org_a' }, rows, ...over, + } as any); + + describe('with the settings service composed', () => { + beforeEach(async () => { await bootKernel(true); }); + + it('the REST door (protocol createData / updateData) converts on create and on an amount change', async () => { + const p = protocol(); + await p.createData({ object: OBJECT, data: { code: 'rest', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, context: { userId: 'u1', tenantId: 'org_a' } }); + expect(storedByCode('rest')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + await p.updateData({ object: OBJECT, id: storedByCode('rest').id, data: { amount: 1389 }, context: { userId: 'u1', tenantId: 'org_a' } }); + expect(storedByCode('rest')).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); + // The home currency was asked of the settings service, for the row's organization. + expect(settingsAsked).toContainEqual({ ns: 'localization', key: 'currency', tenantId: 'org_a' }); + }); + + for (const [label, p] of [['batched (insertManyData)', protocol], ['per-row (createData)', perRow]] as const) { + it(`an import, ${label}, converts each row and refuses a row with no rate in force`, async () => { + const summary = await importRows(p(), [ + { code: 'a', currency_code: 'USD', invoice_date: '2026-03-15', amount: '10000' }, + { code: 'b', currency_code: 'CNY', amount: '12.345' }, + { code: 'c', currency_code: 'USD', invoice_date: '2025-06-30', amount: '1' }, + ]); + expect(summary.results.map((r: any) => r.ok)).toEqual([true, true, false]); + expect(JSON.stringify(summary.results[2])).toContain('ERR_EXCHANGE_RATE_MISSING'); + expect(storedByCode('a')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + expect(storedByCode('b')).toMatchObject({ amount_home: 12.35, amount_rate: 1 }); + expect(Array.from(stub.storeFor(OBJECT).values()).some((r) => r.code === 'c')).toBe(false); + }); + } + + it('a historical (preserveAudit) update-mode import of the home column is recomputed over, not kept', async () => { + await importRows(protocol(), [{ code: 'h', currency_code: 'USD', invoice_date: '2026-03-15', amount: '10000' }]); + const summary = await importRows(perRow(), [{ code: 'h', amount_home: '1', amount_rate: '9' }], { + writeMode: 'update', treatAsHistorical: true, + }); + expect(summary.results.map((r: any) => r.ok)).toEqual([true]); + expect(storedByCode('h')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + }); + + it('an unset localization.currency refuses the REST write ERR_HOME_CURRENCY_UNSET', async () => { + home = undefined; + const err = await protocol().createData({ object: OBJECT, data: { code: 'x', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 }, context: { userId: 'u1' } }) + .then(() => null, (e: { code?: string }) => e); + expect(err?.code).toBe('ERR_HOME_CURRENCY_UNSET'); + expect(stub.storeFor(OBJECT).size).toBe(0); + }); + }); + + it('with no settings service composed, the home currency is unset — refused, never defaulted', async () => { + await bootKernel(false); + const err = await protocol().createData({ object: OBJECT, data: { code: 'x', currency_code: 'CNY', amount: 1 }, context: { userId: 'u1' } }) + .then(() => null, (e: { code?: string }) => e); + expect(err?.code).toBe('ERR_HOME_CURRENCY_UNSET'); + }); +}); diff --git a/packages/qa/dogfood/test/home-amount-projection.test.ts b/packages/qa/dogfood/test/home-amount-projection.test.ts index fa763bc4095..f84c4465ab7 100644 --- a/packages/qa/dogfood/test/home-amount-projection.test.ts +++ b/packages/qa/dogfood/test/home-amount-projection.test.ts @@ -178,6 +178,8 @@ for (const cell of CELLS) { it('the same currency as home is rate 1, rounded to the home minor units', async () => { home = 'CNY'; expect(await written({ currency_code: 'CNY', amount: 12.345 })).toMatchObject({ home: 12.35, rate: 1 }); + // 1.005 is 1.00499999… as a double: a float rounding stores 1.00, the decimal one 1.01. + expect(await written({ currency_code: 'CNY', amount: 1.005 })).toMatchObject({ home: 1.01, rate: 1 }); }); it('the rate row is the latest on or before the document date; none ⇒ refused, nothing stored', async () => { From 2d3501f0fe9d421d87783ca21cd1572226e1cf33 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:19:49 +0000 Subject: [PATCH 13/18] wip: currency.mdx homeAmount passage (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude --- content/docs/data-modeling/currency.mdx | 105 +++++++++++++++++++++++- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/content/docs/data-modeling/currency.mdx b/content/docs/data-modeling/currency.mdx index c09ce58a7a4..38440dea012 100644 --- a/content/docs/data-modeling/currency.mdx +++ b/content/docs/data-modeling/currency.mdx @@ -201,9 +201,108 @@ The organization's home currency is the existing `localization.currency` setting **Metadata never names it**: no field, object or dataset declares the home currency, and a `dynamic` amount displays in it because that is what `dynamic` means. -Consolidating record-mode amounts into one home-currency total, through dated exchange -rates and a stored home amount, comes in a later release. Until then, total a record-mode -amount per currency by grouping by its code field. +To total record-mode amounts across currencies, store each one converted into the home +currency when it is written, as the next section shows, and aggregate the stored home +amount. + +## Converting into the home currency + +### Declaring the home amount + +A record-mode amount declares `currencyConfig.homeAmount` to have the engine store its +value converted into the home currency, together with the rate it used. The declaration +names three fields of the same object, all required: + +| Pointer | Names | Declared as | +|:---|:---|:---| +| `field` | where the home amount is stored | a `currency` field in `dynamic` mode (it displays in the home currency), `readonly: true` | +| `rateDate` | the record's document date, which picks the rate | a `date` or `datetime` field. There is no default: the engine never uses `created_at` in its place. | +| `rateField` | where the rate used is stored | a `number` field, `readonly: true` | + +```typescript +export const Invoice = ObjectSchema.create({ + name: 'invoice', + label: 'Invoice', + fields: { + name: Field.text({ label: 'Number', required: true }), + currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }), + invoice_date: Field.date({ label: 'Invoice Date' }), + amount: Field.currency({ + label: 'Amount', + currencyConfig: { + currencyMode: 'record', + currencyField: 'currency_code', + homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }, + }, + }), + amount_home: Field.currency({ label: 'Amount (home currency)', readonly: true }), + amount_rate: Field.number({ label: 'Exchange rate', readonly: true }), + }, +}); +``` + +The names `amount_home` and `amount_rate` are a convention, not a contract: the platform +never injects these columns and never looks them up by name. + +### The exchange rates + +Rates live in the organization's **Exchange Rates** (Setup, the `sys_exchange_rate` +object): one row per currency and effective date, entered by an organization +administrator, imported, or written by an app flow that fetches a provider feed. A rate +states how many units of the foreign currency **one unit of the home currency** buys: +with home currency USD, an EUR row of `0.92` means 1 USD = 0.92 EUR. A rate keeps up to +twelve decimals. + +### What a write stores + +When a record is inserted, and whenever an update changes its amount, its code or its +document date, the engine computes the home amount: + +- **The rate** is the row for the record's currency with the latest effective date **on + or before** the document date. A row dated after the document never applies, and + neither does "the most recent rate" when it is dated later. A `datetime` document date + is read as its calendar day in UTC. +- **The home amount** is the amount **divided by** the rate, computed in exact decimal + arithmetic and rounded once, half away from zero, to the home currency's minor units: + 0 decimals for JPY, 2 for USD and CNY, 3 for KWD. USD 10,000 at a rate of 0.1389 into + CNY stores ¥71,994.24. +- **The rate field** stores the rate row's own value, unrounded, so the stored home + amount always reproduces from the stored amount and rate. +- **An amount in the home currency itself** stores rate `1` and needs no rate row. +- **A blank amount** stores a blank home amount and a blank rate. + +The home and rate fields are **the engine's alone**. A value a caller sends for either is +dropped like any `readonly` value (or refused under `strictReadonlyWrites`); a +`defaultValue` on either never stands; and a value a system write or a historical import +supplies is computed over. A batch insert and an import convert every row through the +same path as a single write. + +A rate row corrected later does not rewrite the amounts already converted with it: an +update that does not touch the amount, its code or its document date leaves the stored +home amount and rate as they are. + +An update by filter (`multi: true`) writes one patch to every matched row, so it is +refused, with `VALIDATION_FAILED`, when the patch would convert the rows to different home +amounts. Update those rows by id instead, and each is converted from its own row. + +### What a write refuses + +Each refusal is answered before anything is written, and the row is not stored: + +| Refusal | When | Fix | +|:---|:---|:---| +| **`ERR_EXCHANGE_RATE_MISSING` / 400** | No rate row for the record's currency has an effective date on or before its document date. The message names the currency and the date. | Add a rate for that currency effective on or before that date, then write again. The engine never stores a rate of 1, a later rate or an empty home amount in its place. | +| **`ERR_HOME_CURRENCY_UNSET` / 400** | The organization has no home currency: `localization.currency` is not set. | Set **Localization → Default currency**, then write again. The platform never assumes a home currency. | +| `VALIDATION_FAILED` / 400, `required` on the date field | A foreign-currency amount has a blank document date. | Write the document date in the same write. | + +The `validate()` preview, and an import's dry run, answer the same refusals for the same +rows. + +### Totals in the home currency + +The home field is an ordinary stored `currency` field, so every aggregate reads it as it +reads any amount: a `sum` over `amount_home` is the consolidated total, with no grouping, +and a `summary` roll-up, a dataset measure or a grid footer over it needs nothing special. ## See also From 88b8a2b04513dd560313da9ed74742b32d1fe351 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:24:24 +0000 Subject: [PATCH 14/18] wip: changesets; regenerated spec api-surface and reference pages (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude --- .changeset/27192-home-amount-error-codes.md | 14 +++++++++++++ .changeset/27192-home-amount-projection.md | 21 +++++++++++++++++++ .../docs/references/api/automation-api.mdx | 4 +++- content/docs/references/api/contract.mdx | 4 +++- .../docs/references/api/error-code-ledger.mdx | 2 ++ packages/spec/api-surface/data.json | 1 + packages/spec/export-origins/data.json | 1 + 7 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 .changeset/27192-home-amount-error-codes.md create mode 100644 .changeset/27192-home-amount-projection.md diff --git a/.changeset/27192-home-amount-error-codes.md b/.changeset/27192-home-amount-error-codes.md new file mode 100644 index 00000000000..2549aba1793 --- /dev/null +++ b/.changeset/27192-home-amount-error-codes.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': patch +--- + +feat(spec): the error-code ledger registers `ERR_EXCHANGE_RATE_MISSING` and `ERR_HOME_CURRENCY_UNSET`, and `@objectstack/spec/data` exports `CURRENCY_FRACTION_DIGITS` (ADR-0144 D5, Open items 3 and 9) + +Clause-②: yes (widening: two new error codes under `@objectstack/objectql` in `ERROR_CODE_LEDGER`; the `CURRENCY_FRACTION_DIGITS` table, already in the package, is now exported from `@objectstack/spec/data`. No Zod schema shape and no table value changes) +Metadata-protocol: none + +⛔ Not released alone: these ship in release R2 with the write-time home-currency projection that answers them (ADR-0144 D9). + +- `ERR_EXCHANGE_RATE_MISSING` (400): a write that must convert an amount into the home currency found no exchange rate in force on the record's document date. +- `ERR_HOME_CURRENCY_UNSET` (400): the same write found no home currency, because the organization's `localization.currency` setting is not set. +- `CURRENCY_FRACTION_DIGITS` (ISO 4217 minor units per code) is the table the engine rounds a home amount with, exported so a consumer rounds with the same table instead of a copy. diff --git a/.changeset/27192-home-amount-projection.md b/.changeset/27192-home-amount-projection.md new file mode 100644 index 00000000000..220d942e048 --- /dev/null +++ b/.changeset/27192-home-amount-projection.md @@ -0,0 +1,21 @@ +--- +'@objectstack/objectql': patch +--- + +feat(objectql): a record-mode amount that declares `currencyConfig.homeAmount` stores its value converted into the home currency, with the rate used, on every write (ADR-0144 D5) + +Clause-②: yes (widening: `ObjectQL.setHomeCurrencyResolver(resolver)` is new public engine surface; the write refusals below reach only an object that declares `currencyConfig.homeAmount`, a key that ships in this same release, so no released accept set narrows) +Metadata-protocol: none + +⛔ Not released alone: this is the engine half of release R2 (ADR-0144 D9). It ships in the same release as the `currencyConfig.homeAmount` key (`@objectstack/spec`), never ahead of it and never without it. + +When a record is inserted, and when an update changes its amount, its currency code or its document date, the engine writes the two fields `homeAmount` names: + +- **The rate** is the organization's `sys_exchange_rate` row for the record's currency with the latest `effective_date` on or before the document date (`homeAmount.rateDate`). A `datetime` document date is read as its UTC calendar day. The row's rate is stored in `homeAmount.rateField` as it is, unrounded. +- **The home amount** is the amount divided by the rate (a rate states how many units of the foreign currency one unit of the home currency buys), computed in exact decimal arithmetic and rounded once, half away from zero, to the home currency's minor units (0 for JPY, 2 for USD and CNY, 3 for KWD). USD 10,000 at 0.1389 into CNY stores 71994.24. +- **Same currency as home:** rate `1`, no rate read. **Blank amount:** blank home amount and rate. +- **The home currency** is the `localization.currency` setting, read for the row's organization through the `settings` service. `ObjectQLPlugin` wires it; an engine booted without the plugin answers it through `setHomeCurrencyResolver`. +- **The engine's alone.** A value a caller sends for either field is dropped (or refused under `strictReadonlyWrites`) as before; a `defaultValue`, a system write or a historical (`preserveAudit`) import of either field is computed over. +- **Refusals, before anything is written:** `ERR_EXCHANGE_RATE_MISSING` / 400 when no rate row is in force on the document date (the message names the currency and the date; never a rate of 1, a later rate or an empty home amount); `ERR_HOME_CURRENCY_UNSET` / 400 when the organization has no `localization.currency` (never an assumed home currency); `VALIDATION_FAILED` `required` on the date field when a foreign-currency amount has no document date. A multi-row insert (`insertMany`, the import) refuses only the offending row; an update by filter is refused when its one patch would convert the matched rows to different home amounts. +- **Every door, one path.** REST create and update, the import (batched and per row, dry run included) and `validate()` reach the same insert and update seam, so they convert and refuse alike. +- **No migration.** Nothing changes for an amount that does not declare `homeAmount`. diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 2dfd5c4947a..47b869c1952 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' \| … +353 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' \| … +355 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` @@ -483,8 +483,10 @@ const result = AutomationApiErrorCode.parse(data); * `ERR_CURRENCY_CODE_REQUIRED` * `ERR_DATASOURCE_UNAVAILABLE` * `ERR_DRIVER_CONNECT` +* `ERR_EXCHANGE_RATE_MISSING` * `ERR_FILE_CONSTRAINT` * `ERR_FILE_REFERENCE_COPY` +* `ERR_HOME_CURRENCY_UNSET` * `ERR_HOOK_TARGET_REBIND` * `ERR_READONLY_FIELD_REJECTED` * `ERR_SUMMARY_RECOMPUTE` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 2362a57bb8d..6a0aad1ec09 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' \| … +353 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' \| … +355 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`. | @@ -171,8 +171,10 @@ const result = ApiErrorSchema.parse(data); * `ERR_CURRENCY_CODE_REQUIRED` * `ERR_DATASOURCE_UNAVAILABLE` * `ERR_DRIVER_CONNECT` +* `ERR_EXCHANGE_RATE_MISSING` * `ERR_FILE_CONSTRAINT` * `ERR_FILE_REFERENCE_COPY` +* `ERR_HOME_CURRENCY_UNSET` * `ERR_HOOK_TARGET_REBIND` * `ERR_READONLY_FIELD_REJECTED` * `ERR_SUMMARY_RECOMPUTE` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 4101142258b..6c95dd2813a 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -338,8 +338,10 @@ const result = ErrorCode.parse(data); * `ERR_CURRENCY_CODE_REQUIRED` * `ERR_DATASOURCE_UNAVAILABLE` * `ERR_DRIVER_CONNECT` +* `ERR_EXCHANGE_RATE_MISSING` * `ERR_FILE_CONSTRAINT` * `ERR_FILE_REFERENCE_COPY` +* `ERR_HOME_CURRENCY_UNSET` * `ERR_HOOK_TARGET_REBIND` * `ERR_READONLY_FIELD_REJECTED` * `ERR_SUMMARY_RECOMPUTE` diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 866682ee1b9..3141f207c6f 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -98,6 +98,7 @@ "CROSS_FIELD_COMPARISON_CLASSES (const)", "CROSS_FIELD_COMPARISON_TYPE_CLASSES (const)", "CROSS_FIELD_NO_CLASS_REASONS (const)", + "CURRENCY_FRACTION_DIGITS (const)", "CalendarDateValue (type)", "CalendarDateValueSchema (const)", "ClockTimeValue (type)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index ec843a82973..f20d15a5a39 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -95,6 +95,7 @@ "CROSS_FIELD_COMPARISON_CLASSES": "src/data/filter-cross-field-comparison-class.ts#CROSS_FIELD_COMPARISON_CLASSES (const)", "CROSS_FIELD_COMPARISON_TYPE_CLASSES": "src/data/filter-cross-field-comparison-class.ts#CROSS_FIELD_COMPARISON_TYPE_CLASSES (const)", "CROSS_FIELD_NO_CLASS_REASONS": "src/data/filter-cross-field-comparison-class.ts#CROSS_FIELD_NO_CLASS_REASONS (const)", + "CURRENCY_FRACTION_DIGITS": "src/data/currency-fraction-digits.ts#CURRENCY_FRACTION_DIGITS (const)", "CalendarDateValue": "src/data/field-value.zod.ts#CalendarDateValue (type)", "CalendarDateValueSchema": "src/data/field-value.zod.ts#CalendarDateValueSchema (const)", "ClockTimeValue": "src/data/field-value.zod.ts#ClockTimeValue (type)", From fe3f18a55d909bcb728064cec0f89b20a8c7b916 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:31:57 +0000 Subject: [PATCH 15/18] chore(spec): regenerate api-surface and export-origins after merging main Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude --- packages/spec/api-surface/data.json | 1 + packages/spec/export-origins/data.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 3141f207c6f..520b7adf3a5 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -86,6 +86,7 @@ "CALENDAR_DATE_TYPES (const)", "CANONICAL_CREDENTIAL_KEYS (const)", "CLOCK_TIME_TYPES (const)", + "CODE_DEFINED_CREDENTIAL_ROUTE (const)", "COMPOSED_READABLE_IDS_BRAND (const)", "COMPUTED_VALUE_TYPES (const)", "CONTEXT_TOKENS (const)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index f20d15a5a39..4ec83f2a079 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -83,6 +83,7 @@ "CALENDAR_DATE_TYPES": "src/data/field-value.zod.ts#CALENDAR_DATE_TYPES (const)", "CANONICAL_CREDENTIAL_KEYS": "src/data/driver/common.zod.ts#CANONICAL_CREDENTIAL_KEYS (const)", "CLOCK_TIME_TYPES": "src/data/field-value.zod.ts#CLOCK_TIME_TYPES (const)", + "CODE_DEFINED_CREDENTIAL_ROUTE": "src/data/driver/common.zod.ts#CODE_DEFINED_CREDENTIAL_ROUTE (const)", "COMPOSED_READABLE_IDS_BRAND": "src/data/readable-ids.ts#COMPOSED_READABLE_IDS_BRAND (const)", "COMPUTED_VALUE_TYPES": "src/data/field-value.zod.ts#COMPUTED_VALUE_TYPES (const)", "CONTEXT_TOKENS": "src/data/context-tokens.zod.ts#CONTEXT_TOKENS (const)", From c37e52a6687cec1d61aa9227abfd8d7a21b4ca9f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:44:49 +0000 Subject: [PATCH 16/18] fix: drop the merge's duplicate skills paragraph; the write-path test's key column is ref (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude --- .../src/home-amount-write-paths.test.ts | 40 +++++++++---------- skills/objectstack-data/rules/field-types.md | 5 --- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/packages/objectql/src/home-amount-write-paths.test.ts b/packages/objectql/src/home-amount-write-paths.test.ts index d2cdcb41c40..4726917aded 100644 --- a/packages/objectql/src/home-amount-write-paths.test.ts +++ b/packages/objectql/src/home-amount-write-paths.test.ts @@ -58,7 +58,7 @@ function makeStubDriver() { const OBJECT = 'fx_invoice'; const HOME_AMOUNT = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }; const FIELDS = { - code: { name: 'code', type: 'text' }, + ref: { name: 'ref', type: 'text' }, currency_code: { name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' }, invoice_date: { name: 'invoice_date', type: 'date' }, amount: { name: 'amount', type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME_AMOUNT } }, @@ -115,9 +115,9 @@ describe('import and REST writes convert through the same engine path', () => { if (kernel?.getState() === 'running') await kernel.shutdown(); }); - const storedByCode = (code: string) => { - const hits = Array.from(stub.storeFor(OBJECT).values()).filter((r) => r.code === code); - expect(hits.length, `one stored row for ${code}`).toBe(1); + const storedByRef = (ref: string) => { + const hits = Array.from(stub.storeFor(OBJECT).values()).filter((r) => r.ref === ref); + expect(hits.length, `one stored row for ${ref}`).toBe(1); return hits[0]!; }; const protocol = () => new ObjectStackProtocolImplementation(objectql as never) as unknown as ImportProtocolLike & { @@ -129,7 +129,7 @@ describe('import and REST writes convert through the same engine path', () => { }; const metaMap = new Map(Object.entries(FIELDS)); const importRows = (p: ImportProtocolLike, rows: Array>, over: Record = {}) => runImport({ - p, objectName: OBJECT, metaMap, writeMode: 'insert', matchFields: ['code'], dryRun: false, runAutomations: false, + p, objectName: OBJECT, metaMap, writeMode: 'insert', matchFields: ['ref'], dryRun: false, runAutomations: false, trimWhitespace: true, skipBlankMatchKey: false, context: { userId: 'usr_importer', tenantId: 'org_a' }, rows, ...over, } as any); @@ -138,10 +138,10 @@ describe('import and REST writes convert through the same engine path', () => { it('the REST door (protocol createData / updateData) converts on create and on an amount change', async () => { const p = protocol(); - await p.createData({ object: OBJECT, data: { code: 'rest', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, context: { userId: 'u1', tenantId: 'org_a' } }); - expect(storedByCode('rest')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); - await p.updateData({ object: OBJECT, id: storedByCode('rest').id, data: { amount: 1389 }, context: { userId: 'u1', tenantId: 'org_a' } }); - expect(storedByCode('rest')).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); + await p.createData({ object: OBJECT, data: { ref: 'rest', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, context: { userId: 'u1', tenantId: 'org_a' } }); + expect(storedByRef('rest')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + await p.updateData({ object: OBJECT, id: storedByRef('rest').id, data: { amount: 1389 }, context: { userId: 'u1', tenantId: 'org_a' } }); + expect(storedByRef('rest')).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); // The home currency was asked of the settings service, for the row's organization. expect(settingsAsked).toContainEqual({ ns: 'localization', key: 'currency', tenantId: 'org_a' }); }); @@ -149,30 +149,30 @@ describe('import and REST writes convert through the same engine path', () => { for (const [label, p] of [['batched (insertManyData)', protocol], ['per-row (createData)', perRow]] as const) { it(`an import, ${label}, converts each row and refuses a row with no rate in force`, async () => { const summary = await importRows(p(), [ - { code: 'a', currency_code: 'USD', invoice_date: '2026-03-15', amount: '10000' }, - { code: 'b', currency_code: 'CNY', amount: '12.345' }, - { code: 'c', currency_code: 'USD', invoice_date: '2025-06-30', amount: '1' }, + { ref: 'a', currency_code: 'USD', invoice_date: '2026-03-15', amount: '10000' }, + { ref: 'b', currency_code: 'CNY', amount: '12.345' }, + { ref: 'c', currency_code: 'USD', invoice_date: '2025-06-30', amount: '1' }, ]); expect(summary.results.map((r: any) => r.ok)).toEqual([true, true, false]); expect(JSON.stringify(summary.results[2])).toContain('ERR_EXCHANGE_RATE_MISSING'); - expect(storedByCode('a')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); - expect(storedByCode('b')).toMatchObject({ amount_home: 12.35, amount_rate: 1 }); - expect(Array.from(stub.storeFor(OBJECT).values()).some((r) => r.code === 'c')).toBe(false); + expect(storedByRef('a')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + expect(storedByRef('b')).toMatchObject({ amount_home: 12.35, amount_rate: 1 }); + expect(Array.from(stub.storeFor(OBJECT).values()).some((r) => r.ref === 'c')).toBe(false); }); } it('a historical (preserveAudit) update-mode import of the home column is recomputed over, not kept', async () => { - await importRows(protocol(), [{ code: 'h', currency_code: 'USD', invoice_date: '2026-03-15', amount: '10000' }]); - const summary = await importRows(perRow(), [{ code: 'h', amount_home: '1', amount_rate: '9' }], { + await importRows(protocol(), [{ ref: 'h', currency_code: 'USD', invoice_date: '2026-03-15', amount: '10000' }]); + const summary = await importRows(perRow(), [{ ref: 'h', amount_home: '1', amount_rate: '9' }], { writeMode: 'update', treatAsHistorical: true, }); expect(summary.results.map((r: any) => r.ok)).toEqual([true]); - expect(storedByCode('h')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + expect(storedByRef('h')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); }); it('an unset localization.currency refuses the REST write ERR_HOME_CURRENCY_UNSET', async () => { home = undefined; - const err = await protocol().createData({ object: OBJECT, data: { code: 'x', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 }, context: { userId: 'u1' } }) + const err = await protocol().createData({ object: OBJECT, data: { ref: 'x', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 }, context: { userId: 'u1' } }) .then(() => null, (e: { code?: string }) => e); expect(err?.code).toBe('ERR_HOME_CURRENCY_UNSET'); expect(stub.storeFor(OBJECT).size).toBe(0); @@ -181,7 +181,7 @@ describe('import and REST writes convert through the same engine path', () => { it('with no settings service composed, the home currency is unset — refused, never defaulted', async () => { await bootKernel(false); - const err = await protocol().createData({ object: OBJECT, data: { code: 'x', currency_code: 'CNY', amount: 1 }, context: { userId: 'u1' } }) + const err = await protocol().createData({ object: OBJECT, data: { ref: 'x', currency_code: 'CNY', amount: 1 }, context: { userId: 'u1' } }) .then(() => null, (e: { code?: string }) => e); expect(err?.code).toBe('ERR_HOME_CURRENCY_UNSET'); }); diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 1bcf225fe43..fee7a516257 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -181,11 +181,6 @@ home-currency amount in `field` (a `'dynamic'` `currency`) at the `rateDate` (`date`) rate, and that rate in `rateField` (`number`), both `readonly: true`. Cross-currency totals sum `field`. -`homeAmount` (`'record'` only, all three required): the engine stores the -home-currency amount in `field` (a `'dynamic'` `currency`) at the `rateDate` -(`date`) rate, and that rate in `rateField` (`number`), both `readonly: true`. -Cross-currency totals sum `field`. - ### Select with Default ```typescript From 2fe4a2cd6b4e6fbe2476bb503e33e9f75e422abf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:46:58 +0000 Subject: [PATCH 17/18] test: rates are per organization on the real drivers; CLDR fraction digits, not ISO 4217 minor units; the scale pin's header matches its body (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude --- .changeset/27192-home-amount-error-codes.md | 2 +- .changeset/27192-home-amount-projection.md | 2 +- content/docs/data-modeling/currency.mdx | 6 ++-- .../src/engine-home-amount-projection.test.ts | 15 +++++--- .../objectql/src/home-amount-projection.ts | 8 +++-- .../test/home-amount-projection.test.ts | 35 ++++++++++++++++++- packages/spec/src/data/index.ts | 12 ++++--- 7 files changed, 62 insertions(+), 18 deletions(-) diff --git a/.changeset/27192-home-amount-error-codes.md b/.changeset/27192-home-amount-error-codes.md index 2549aba1793..70d770700a8 100644 --- a/.changeset/27192-home-amount-error-codes.md +++ b/.changeset/27192-home-amount-error-codes.md @@ -11,4 +11,4 @@ Metadata-protocol: none - `ERR_EXCHANGE_RATE_MISSING` (400): a write that must convert an amount into the home currency found no exchange rate in force on the record's document date. - `ERR_HOME_CURRENCY_UNSET` (400): the same write found no home currency, because the organization's `localization.currency` setting is not set. -- `CURRENCY_FRACTION_DIGITS` (ISO 4217 minor units per code) is the table the engine rounds a home amount with, exported so a consumer rounds with the same table instead of a copy. +- `CURRENCY_FRACTION_DIGITS` (CLDR fraction digits per code, which differ from ISO 4217's minor units for some codes such as IQD, HUF, COP, IDR and PKR) is the table the engine rounds a home amount with, exported so a consumer rounds with the same table instead of a copy. diff --git a/.changeset/27192-home-amount-projection.md b/.changeset/27192-home-amount-projection.md index 220d942e048..100e4020982 100644 --- a/.changeset/27192-home-amount-projection.md +++ b/.changeset/27192-home-amount-projection.md @@ -12,7 +12,7 @@ Metadata-protocol: none When a record is inserted, and when an update changes its amount, its currency code or its document date, the engine writes the two fields `homeAmount` names: - **The rate** is the organization's `sys_exchange_rate` row for the record's currency with the latest `effective_date` on or before the document date (`homeAmount.rateDate`). A `datetime` document date is read as its UTC calendar day. The row's rate is stored in `homeAmount.rateField` as it is, unrounded. -- **The home amount** is the amount divided by the rate (a rate states how many units of the foreign currency one unit of the home currency buys), computed in exact decimal arithmetic and rounded once, half away from zero, to the home currency's minor units (0 for JPY, 2 for USD and CNY, 3 for KWD). USD 10,000 at 0.1389 into CNY stores 71994.24. +- **The home amount** is the amount divided by the rate (a rate states how many units of the foreign currency one unit of the home currency buys), computed in exact decimal arithmetic and rounded once, half away from zero, to the home currency's CLDR fraction digits (0 for JPY, 2 for USD and CNY, 3 for KWD; for a few codes, such as IQD, HUF, COP, IDR and PKR, CLDR's count differs from ISO 4217's minor units, and CLDR's applies). USD 10,000 at 0.1389 into CNY stores 71994.24. - **Same currency as home:** rate `1`, no rate read. **Blank amount:** blank home amount and rate. - **The home currency** is the `localization.currency` setting, read for the row's organization through the `settings` service. `ObjectQLPlugin` wires it; an engine booted without the plugin answers it through `setHomeCurrencyResolver`. - **The engine's alone.** A value a caller sends for either field is dropped (or refused under `strictReadonlyWrites`) as before; a `defaultValue`, a system write or a historical (`preserveAudit`) import of either field is computed over. diff --git a/content/docs/data-modeling/currency.mdx b/content/docs/data-modeling/currency.mdx index 38440dea012..80139e9b613 100644 --- a/content/docs/data-modeling/currency.mdx +++ b/content/docs/data-modeling/currency.mdx @@ -263,8 +263,10 @@ document date, the engine computes the home amount: neither does "the most recent rate" when it is dated later. A `datetime` document date is read as its calendar day in UTC. - **The home amount** is the amount **divided by** the rate, computed in exact decimal - arithmetic and rounded once, half away from zero, to the home currency's minor units: - 0 decimals for JPY, 2 for USD and CNY, 3 for KWD. USD 10,000 at a rate of 0.1389 into + arithmetic and rounded once, half away from zero, to the home currency's decimal + places as CLDR gives them (its fraction digits): 0 for JPY, 2 for USD and CNY, 3 for + KWD. For a few currencies CLDR's count differs from ISO 4217's minor units (IQD, HUF, + COP, IDR and PKR among them), and CLDR's applies. USD 10,000 at a rate of 0.1389 into CNY stores ¥71,994.24. - **The rate field** stores the rate row's own value, unrounded, so the stored home amount always reproduces from the stored amount and rate. diff --git a/packages/objectql/src/engine-home-amount-projection.test.ts b/packages/objectql/src/engine-home-amount-projection.test.ts index a92d8659aec..67e8858e777 100644 --- a/packages/objectql/src/engine-home-amount-projection.test.ts +++ b/packages/objectql/src/engine-home-amount-projection.test.ts @@ -6,20 +6,25 @@ // orderBy, limit, fields), so the rate row the projection picks is chosen by // the same `effective_date <= date ORDER BY effective_date DESC LIMIT 1` a // SQL driver runs. ⛔ No driver-memory arm: its test consumers are a closed -// census (`check:driver-memory-census`). The same pins on SQLite and Postgres -// are packages/qa/dogfood's `test/home-amount-projection.test.ts`. +// census (`check:driver-memory-census`). The double ignores `tenantId`, so +// organization isolation of the rate read is not pinned here: the same pins, +// and the two-organization one, on SQLite and Postgres are packages/qa/dogfood's +// `test/home-amount-projection.test.ts`. // // Pinned here, per seam (insert, insertMany, by-id update, predicate update, // the validate() preview): // -// - the stored home amount is amount ÷ rate, rounded to the home minor units, +// - the stored home amount is amount ÷ rate, rounded to the home currency's +// CLDR fraction digits, // stored with the rate used; the same currency as home is rate 1, read-free; // - a missing rate refuses ERR_EXCHANGE_RATE_MISSING and an unset home // currency ERR_HOME_CURRENCY_UNSET, and nothing is written; // - the two projected fields: a caller's value is stripped (`readonly`) and // recomputed over, a system or `preserveAudit` writer's value is recomputed -// over, a `defaultValue` never stands, a rate field `scale` below the -// rate's decimals refuses `max_scale` (never rounded); +// over, a `defaultValue` never stands; a rate field declaring a `scale` +// below the rate's decimals stores the full rate, never rounded and never +// refused (a `readonly` value is checked for shape only, so the declared +// scale reaches display only); // - an update projects when it moves the amount, its code or its document // date, and never otherwise (a corrected rate reaches stored rows only // through a recompute). diff --git a/packages/objectql/src/home-amount-projection.ts b/packages/objectql/src/home-amount-projection.ts index 08fcb883cca..5274d08d5e9 100644 --- a/packages/objectql/src/home-amount-projection.ts +++ b/packages/objectql/src/home-amount-projection.ts @@ -19,7 +19,7 @@ * `ERR_HOME_CURRENCY_UNSET` / 400 (ADR-0144 Open item 4): the sources rule * out a silent default, so the engine never picks one. * - **The same currency as home** stores rate `1` and the amount rounded to - * the home currency's minor units, with no rate lookup and no rate row + * the home currency's fraction digits, with no rate lookup and no rate row * needed (Open item 6). * - **A foreign currency** reads the rate row of that currency with the latest * `effective_date` on or before the record's document date (Open item 2): @@ -31,8 +31,10 @@ * foreign currency", so the home amount is the record amount DIVIDED by the * rate. It is exact decimal arithmetic on the decimal spellings of the two * numbers (scaled integers, never a floating-point division), rounded once, - * half away from zero, to the home currency's ISO 4217 minor units read from - * `CURRENCY_FRACTION_DIGITS` (Open item 3): JPY 0, USD 2, KWD 3. + * half away from zero, to the home currency's CLDR fraction digits read from + * `CURRENCY_FRACTION_DIGITS` (Open item 3): JPY 0, USD 2, KWD 3. CLDR's + * count differs from ISO 4217's minor units for some codes (IQD, HUF, COP, + * IDR, PKR among them); the table is CLDR's, and so is the rounding. * - **The rate stored** is the rate row's own value, never rounded (D1: the * amount is stored "with the rate used"; a rounded rate would not reproduce * the stored home amount). The rate field is `readonly`, and a readonly diff --git a/packages/qa/dogfood/test/home-amount-projection.test.ts b/packages/qa/dogfood/test/home-amount-projection.test.ts index f84c4465ab7..f96fc079889 100644 --- a/packages/qa/dogfood/test/home-amount-projection.test.ts +++ b/packages/qa/dogfood/test/home-amount-projection.test.ts @@ -10,7 +10,8 @@ // Cells: better-sqlite3 and sqlite-wasm always run; PostgreSQL runs where // `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. The Postgres // cell owns one schema, named from this file, created before and dropped -// after. The in-memory face of the same pins (and the strips, defaults, +// after. One pin runs under the `isolated` posture with two organizations: a +// rate row is read in the written row's organization only. The in-memory face of the same pins (and the strips, defaults, // preview and predicate-update cells) is packages/objectql's // `engine-home-amount-projection.test.ts`. @@ -194,6 +195,38 @@ for (const cell of CELLS) { expect((await engine.find('fx_invoice', { context: SYS.context } as never)).length).toBe(before); }); + it('rates are per organization: one organization\'s rate row never converts another\'s write', async () => { + home = 'CNY'; + // Organizations are walled from each other under the `isolated` posture; + // `single` is one organization by definition and reads install-wide. + engine.setTenancyPostureProvider(() => 'isolated'); + try { + const driverOf = engine.getDriverForObject('sys_organization')!; + for (const id of ['org_fx_a', 'org_fx_b']) { + if (!(await driverOf.findOne('sys_organization', { where: { id } } as never))) await driverOf.create('sys_organization', { id, name: id }); + } + const as = (org: string) => ({ context: { isSystem: true, tenantId: org } }); + // SEK has a rate in organization A only; NOK in both, at different rates. + await engine.insert('sys_exchange_rate', { currency: 'SEK', effective_date: '2026-01-01', rate: 1.5, source: 'manual' }, as('org_fx_a')); + await engine.insert('sys_exchange_rate', { currency: 'NOK', effective_date: '2026-01-01', rate: 1.6, source: 'manual' }, as('org_fx_a')); + await engine.insert('sys_exchange_rate', { currency: 'NOK', effective_date: '2026-01-01', rate: 2, source: 'manual' }, as('org_fx_b')); + + const inA = await engine.insert('fx_invoice', { name: 'a', currency_code: 'SEK', invoice_date: '2026-03-15', amount: 300 }, as('org_fx_a')); + expect(await read(inA.id)).toEqual({ home: 200, rate: 1.5, org: 'org_fx_a' }); + const nokA = await engine.insert('fx_invoice', { name: 'a2', currency_code: 'NOK', invoice_date: '2026-03-15', amount: 320 }, as('org_fx_a')); + expect(await read(nokA.id)).toEqual({ home: 200, rate: 1.6, org: 'org_fx_a' }); + const nokB = await engine.insert('fx_invoice', { name: 'b', currency_code: 'NOK', invoice_date: '2026-03-15', amount: 320 }, as('org_fx_b')); + expect(await read(nokB.id)).toEqual({ home: 160, rate: 2, org: 'org_fx_b' }); + + // Organization B has no SEK rate: A's row is not read for it, so the write is refused. + const err = await engine.insert('fx_invoice', { name: 'b2', currency_code: 'SEK', invoice_date: '2026-03-15', amount: 300 }, as('org_fx_b')) + .then(() => null, (e: { code?: string }) => e); + expect(err?.code).toBe('ERR_EXCHANGE_RATE_MISSING'); + } finally { + engine.setTenancyPostureProvider(() => undefined); + } + }); + it('an update of the amount, the code or the date converts again; another edit does not', async () => { home = 'CNY'; const { id } = await engine.insert('fx_invoice', { name: 'u', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, SYS); diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index beb611aafc9..281e4c9288b 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -180,11 +180,13 @@ export * from './api-derivation'; // so the two can never drift apart (#7521). export * from './managed-api-affordance'; export * from './field.zod'; -// [ADR-0144 Open item 3] The ISO 4217 minor units per currency — the checked-in -// CLDR snapshot whose key set is the `iso_4217_currency` value domain. Exported -// for its first digit reader: objectql's write-time home projection rounds a -// home amount to the home currency's minor units with it, from this one table -// rather than a second copy or a runtime `Intl` probe. +// [ADR-0144 Open item 3] The CLDR fraction digits per currency — the checked-in +// CLDR snapshot whose key set is the `iso_4217_currency` value domain. Its +// digit counts are CLDR's, which differ from ISO 4217's minor units for some +// codes (IQD 0 vs 3; HUF, COP, IDR, PKR 0 vs 2). Exported for its first digit +// reader: objectql's write-time home projection rounds a home amount to the +// home currency's CLDR fraction digits with it, from this one table rather +// than a second copy or a runtime `Intl` probe. export * from './currency-fraction-digits'; // The credential read mask (ADR-0100) — the ONE string a masked read serves, in // place of the two byte-identical literals objectql and service-settings each From 321991c7d13e8cdf74efef63c1053c1be1e0c71b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:15:35 +0000 Subject: [PATCH 18/18] feat(objectql): a datetime document date is dated in the organization's localization.timezone; the R2 core ships with R1 (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude --- .changeset/27192-home-amount-error-codes.md | 2 +- .changeset/27192-home-amount-projection.md | 8 +- content/docs/data-modeling/currency.mdx | 4 +- .../src/engine-home-amount-projection.test.ts | 57 +++++++++++++- packages/objectql/src/engine.ts | 29 +++++-- .../src/home-amount-projection.test.ts | 49 +++++++++--- .../objectql/src/home-amount-projection.ts | 77 ++++++++++++++++--- .../src/home-amount-write-paths.test.ts | 45 +++++++++-- packages/objectql/src/plugin.ts | 51 ++++++++---- .../test/home-amount-projection.test.ts | 36 ++++++++- 10 files changed, 300 insertions(+), 58 deletions(-) diff --git a/.changeset/27192-home-amount-error-codes.md b/.changeset/27192-home-amount-error-codes.md index 70d770700a8..f2e82deec99 100644 --- a/.changeset/27192-home-amount-error-codes.md +++ b/.changeset/27192-home-amount-error-codes.md @@ -7,7 +7,7 @@ feat(spec): the error-code ledger registers `ERR_EXCHANGE_RATE_MISSING` and `ERR Clause-②: yes (widening: two new error codes under `@objectstack/objectql` in `ERROR_CODE_LEDGER`; the `CURRENCY_FRACTION_DIGITS` table, already in the package, is now exported from `@objectstack/spec/data`. No Zod schema shape and no table value changes) Metadata-protocol: none -⛔ Not released alone: these ship in release R2 with the write-time home-currency projection that answers them (ADR-0144 D9). +⛔ Not released alone: these are part of the R2 core (ADR-0144 D9) and ship with R1 in 18.3.0, together with the write-time home-currency projection that answers them, the `sys_exchange_rate` table (#27185) and the `localization.currency` lock (#27198). - `ERR_EXCHANGE_RATE_MISSING` (400): a write that must convert an amount into the home currency found no exchange rate in force on the record's document date. - `ERR_HOME_CURRENCY_UNSET` (400): the same write found no home currency, because the organization's `localization.currency` setting is not set. diff --git a/.changeset/27192-home-amount-projection.md b/.changeset/27192-home-amount-projection.md index 100e4020982..79e8fa1bfc4 100644 --- a/.changeset/27192-home-amount-projection.md +++ b/.changeset/27192-home-amount-projection.md @@ -4,17 +4,17 @@ feat(objectql): a record-mode amount that declares `currencyConfig.homeAmount` stores its value converted into the home currency, with the rate used, on every write (ADR-0144 D5) -Clause-②: yes (widening: `ObjectQL.setHomeCurrencyResolver(resolver)` is new public engine surface; the write refusals below reach only an object that declares `currencyConfig.homeAmount`, a key that ships in this same release, so no released accept set narrows) +Clause-②: yes (widening: `ObjectQL.setHomeLocalizationResolver(resolver)` is new public engine surface; the write refusals below reach only an object that declares `currencyConfig.homeAmount`, a key that ships in this same release, so no released accept set narrows) Metadata-protocol: none -⛔ Not released alone: this is the engine half of release R2 (ADR-0144 D9). It ships in the same release as the `currencyConfig.homeAmount` key (`@objectstack/spec`), never ahead of it and never without it. +⛔ Not released alone: this is the engine half of the R2 core (ADR-0144 D9). It ships with R1 in 18.3.0, together with the `currencyConfig.homeAmount` key (#27191), the `sys_exchange_rate` table (#27185) and the `localization.currency` lock (#27198). When a record is inserted, and when an update changes its amount, its currency code or its document date, the engine writes the two fields `homeAmount` names: -- **The rate** is the organization's `sys_exchange_rate` row for the record's currency with the latest `effective_date` on or before the document date (`homeAmount.rateDate`). A `datetime` document date is read as its UTC calendar day. The row's rate is stored in `homeAmount.rateField` as it is, unrounded. +- **The rate** is the organization's `sys_exchange_rate` row for the record's currency with the latest `effective_date` on or before the document date (`homeAmount.rateDate`). A `datetime` document date is dated in the organization's `localization.timezone` (UTC when unset): an invoice written at 01:00 in Shanghai takes that Shanghai day's rate. A `date` is its own day. The row's rate is stored in `homeAmount.rateField` as it is, unrounded. - **The home amount** is the amount divided by the rate (a rate states how many units of the foreign currency one unit of the home currency buys), computed in exact decimal arithmetic and rounded once, half away from zero, to the home currency's CLDR fraction digits (0 for JPY, 2 for USD and CNY, 3 for KWD; for a few codes, such as IQD, HUF, COP, IDR and PKR, CLDR's count differs from ISO 4217's minor units, and CLDR's applies). USD 10,000 at 0.1389 into CNY stores 71994.24. - **Same currency as home:** rate `1`, no rate read. **Blank amount:** blank home amount and rate. -- **The home currency** is the `localization.currency` setting, read for the row's organization through the `settings` service. `ObjectQLPlugin` wires it; an engine booted without the plugin answers it through `setHomeCurrencyResolver`. +- **The home currency** is the `localization.currency` setting, read for the row's organization through the `settings` service, in one grouped read with `localization.timezone`. `ObjectQLPlugin` wires it; an engine booted without the plugin answers both through `setHomeLocalizationResolver`. - **The engine's alone.** A value a caller sends for either field is dropped (or refused under `strictReadonlyWrites`) as before; a `defaultValue`, a system write or a historical (`preserveAudit`) import of either field is computed over. - **Refusals, before anything is written:** `ERR_EXCHANGE_RATE_MISSING` / 400 when no rate row is in force on the document date (the message names the currency and the date; never a rate of 1, a later rate or an empty home amount); `ERR_HOME_CURRENCY_UNSET` / 400 when the organization has no `localization.currency` (never an assumed home currency); `VALIDATION_FAILED` `required` on the date field when a foreign-currency amount has no document date. A multi-row insert (`insertMany`, the import) refuses only the offending row; an update by filter is refused when its one patch would convert the matched rows to different home amounts. - **Every door, one path.** REST create and update, the import (batched and per row, dry run included) and `validate()` reach the same insert and update seam, so they convert and refuse alike. diff --git a/content/docs/data-modeling/currency.mdx b/content/docs/data-modeling/currency.mdx index 80139e9b613..e489f45c61e 100644 --- a/content/docs/data-modeling/currency.mdx +++ b/content/docs/data-modeling/currency.mdx @@ -261,7 +261,9 @@ document date, the engine computes the home amount: - **The rate** is the row for the record's currency with the latest effective date **on or before** the document date. A row dated after the document never applies, and neither does "the most recent rate" when it is dated later. A `datetime` document date - is read as its calendar day in UTC. + is dated in the organization's timezone (the `localization.timezone` setting, UTC when + unset): an invoice written at 01:00 on April 1 in Shanghai takes the April 1 rate. A + `date` document date is its own day. - **The home amount** is the amount **divided by** the rate, computed in exact decimal arithmetic and rounded once, half away from zero, to the home currency's decimal places as CLDR gives them (its fraction digits): 0 for JPY, 2 for USD and CNY, 3 for diff --git a/packages/objectql/src/engine-home-amount-projection.test.ts b/packages/objectql/src/engine-home-amount-projection.test.ts index 67e8858e777..1e5c5bd3744 100644 --- a/packages/objectql/src/engine-home-amount-projection.test.ts +++ b/packages/objectql/src/engine-home-amount-projection.test.ts @@ -25,6 +25,8 @@ // below the rate's decimals stores the full rate, never rounded and never // refused (a `readonly` value is checked for shape only, so the declared // scale reaches display only); +// - a `datetime` document date is dated in the organization's +// `localization.timezone` (UTC when unset); a `date` is its own day; // - an update projects when it moves the amount, its code or its document // date, and never otherwise (a corrected rate reaches stored rows only // through a recompute). @@ -101,12 +103,12 @@ const RATE_OBJECT = { }; const HOME_AMOUNT = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }; -const invoice = (over: { home?: Record; rate?: Record } = {}) => ({ +const invoice = (over: { home?: Record; rate?: Record; dateType?: 'date' | 'datetime' } = {}) => ({ name: 'fx_invoice', fields: { title: { type: 'text' }, currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, - invoice_date: { type: 'date' }, + invoice_date: { type: over.dateType ?? 'date' }, amount: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME_AMOUNT } }, amount_home: { type: 'currency', readonly: true, ...over.home }, amount_rate: { type: 'number', readonly: true, ...over.rate }, @@ -127,7 +129,7 @@ async function boot(object = invoice(), home: string | null = 'CNY') { await engine.init(); engine.registry.registerObject(RATE_OBJECT as any); engine.registry.registerObject(object as any); - if (home !== null) engine.setHomeCurrencyResolver(async () => home); + if (home !== null) engine.setHomeLocalizationResolver(async () => ({ currency: home })); const rate = (currency: string, effective_date: string, value: number) => d.storeFor('sys_exchange_rate').set(`${currency}_${effective_date}`, { id: `${currency}_${effective_date}`, currency, effective_date, rate: value }); return { engine, ...d, rate }; @@ -219,12 +221,59 @@ describe('the home currency is the organization\'s setting, never a default', () it('the resolver is asked for the row\'s organization', async () => { const t = await boot(invoice(), null); const asked: Array = []; - t.engine.setHomeCurrencyResolver(async (org) => { asked.push(org); return 'CNY'; }); + t.engine.setHomeLocalizationResolver(async (org) => { asked.push(org); return { currency: 'CNY' }; }); await t.engine.insert('fx_invoice', { title: 'i1', currency_code: 'CNY', amount: 1 }, { context: { isSystem: true, tenantId: 'org_a' } } as any); expect(asked).toEqual(['org_a']); }); }); +describe('a datetime document date is dated in the organization\'s localization.timezone', () => { + // 2026-03-15T17:00Z is 01:00 on 2026-03-16 in Shanghai, and still the 15th in UTC. + const AT_0100_SHANGHAI = '2026-03-15T17:00:00Z'; + async function bootZoned(zones: Record) { + const t = await boot(invoice({ dateType: 'datetime' }), null); + const asked: Array = []; + t.engine.setHomeLocalizationResolver(async (org) => { asked.push(org); return { currency: 'CNY', timezone: zones[org ?? ''] }; }); + t.rate('USD', '2026-03-15', 0.1389); + t.rate('USD', '2026-03-16', 0.15); + return { ...t, asked }; + } + const as = (org: string) => ({ context: { isSystem: true, tenantId: org } }) as any; + + it('an Asia/Shanghai organization writing at 01:00 local time gets the rate of that local day', async () => { + const t = await bootZoned({ org_sh: 'Asia/Shanghai' }); + const row = await t.engine.insert('fx_invoice', { title: 'sh', currency_code: 'USD', invoice_date: AT_0100_SHANGHAI, amount: 1500 }, as('org_sh')); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 10000, amount_rate: 0.15 }); + // One localization read per organization per write answers the currency and the timezone. + expect(t.asked).toEqual(['org_sh']); + }); + + it('a UTC organization, or one with no timezone set, keeps the UTC day', async () => { + const t = await bootZoned({ org_utc: 'UTC', org_unset: undefined }); + for (const org of ['org_utc', 'org_unset']) { + const row = await t.engine.insert('fx_invoice', { title: org, currency_code: 'USD', invoice_date: AT_0100_SHANGHAI, amount: 1389 }, as(org)); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); + } + }); + + it('an update of the datetime re-dates it in the same timezone', async () => { + const t = await bootZoned({ org_sh: 'Asia/Shanghai' }); + const row = await t.engine.insert('fx_invoice', { title: 'sh', currency_code: 'USD', invoice_date: '2026-03-15T03:00:00Z', amount: 1389 }, as('org_sh')); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); + await t.engine.update('fx_invoice', { id: row.id, invoice_date: AT_0100_SHANGHAI }, as('org_sh')); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 9260, amount_rate: 0.15 }); + }); + + it('a date document date is never moved by the timezone', async () => { + const t = await boot(invoice(), null); + t.engine.setHomeLocalizationResolver(async () => ({ currency: 'CNY', timezone: 'Asia/Shanghai' })); + t.rate('USD', '2026-03-15', 0.1389); + t.rate('USD', '2026-03-16', 0.15); + const row = await t.engine.insert('fx_invoice', { title: 'd', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1389 }, as('org_sh')); + expect(t.storeFor('fx_invoice').get(row.id)).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); + }); +}); + describe('only the engine writes the home and rate fields', () => { it('a caller\'s value is stripped as readonly and the projection is stored', async () => { const t = await boot(); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index a983966d2a5..a50936956b9 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -324,6 +324,8 @@ import { settleBulkHomeAmounts, type HomeAmountPlanEntry, type HomeAmountServing, + type HomeLocalization, + type HomeLocalizationResolver, } from './home-amount-projection.js'; import { assertAggregatedColumnsAreDistinct, assertDateBucketTargetsAreTemporal } from './aggregate-query-shape-door.js'; import { applyProtection, pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared'; @@ -9666,18 +9668,20 @@ export class ObjectQL implements IObjectQLEngine { /** * [ADR-0144 D4, Open item 4] Where the write-time home projection reads an - * organization's home currency: its `localization.currency` setting, or + * organization's localization: its `localization.currency` setting (the + * home currency) and its `localization.timezone` setting (the zone a + * `datetime` document date is dated in; ruling 6539345871, letter B), each * `undefined` when unset. Bridged by `ObjectQLPlugin`, which resolves the * settings service when a write asks, not when it starts. With no resolver * every organization's home currency is unset, so a write of an amount * declaring `homeAmount` is refused `ERR_HOME_CURRENCY_UNSET` — the engine - * never assumes one. + * never assumes one — and an unset timezone reads UTC. */ - setHomeCurrencyResolver(resolver: ((organizationId: string | undefined) => Promise) | undefined): void { - this.homeCurrencyResolver = resolver; + setHomeLocalizationResolver(resolver: HomeLocalizationResolver | undefined): void { + this.homeLocalizationResolver = resolver; } - private homeCurrencyResolver?: (organizationId: string | undefined) => Promise; + private homeLocalizationResolver?: HomeLocalizationResolver; /** * [ADR-0144 D5, Open item 2] What one write's home projection reads, memoized @@ -9695,8 +9699,21 @@ export class ObjectQL implements IObjectQLEngine { * currency. */ private homeAmountServing(context: ExecutionContext | undefined): HomeAmountServing { + // One settings read per organization per write answers both the home + // currency and the timezone. + const localizations = new Map>(); + const localizationOf = (organizationId: string | undefined) => { + const key = organizationId ?? ''; + let hit = localizations.get(key); + if (!hit) { + hit = Promise.resolve(this.homeLocalizationResolver?.(organizationId)); + localizations.set(key, hit); + } + return hit; + }; return memoizeHomeAmountServing({ - homeCurrency: async (organizationId) => this.homeCurrencyResolver?.(organizationId), + homeCurrency: async (organizationId) => (await localizationOf(organizationId))?.currency, + timezone: async (organizationId) => (await localizationOf(organizationId))?.timezone, rateOn: async (organizationId, currency, onOrBefore) => { if (!this._registry.getObject(EXCHANGE_RATE_OBJECT)) return undefined; const rows = await this.find(EXCHANGE_RATE_OBJECT, { diff --git a/packages/objectql/src/home-amount-projection.test.ts b/packages/objectql/src/home-amount-projection.test.ts index 6ac64fca752..ad91cd426a1 100644 --- a/packages/objectql/src/home-amount-projection.test.ts +++ b/packages/objectql/src/home-amount-projection.test.ts @@ -39,13 +39,17 @@ const SCHEMA = { const PLAN = planHomeAmounts(SCHEMA); /** A served rate table: `rates[currency]` is a list of `[effective_date, rate]`; reads are counted. */ -function serving(home: string | undefined, rates: Record> = {}) { - const reads = { home: 0, rate: 0 }; +function serving(home: string | undefined, rates: Record> = {}, timezone?: string) { + const reads = { home: 0, rate: 0, timezone: 0 }; const s: HomeAmountServing = { async homeCurrency() { reads.home += 1; return home; }, + async timezone() { + reads.timezone += 1; + return timezone; + }, async rateOn(_org, currency, onOrBefore) { reads.rate += 1; const hit = (rates[currency] ?? []) @@ -93,7 +97,7 @@ describe('the exact decimal arithmetic', () => { }); describe('the document date', () => { - it('a date is its own day; a datetime instant is its UTC day', () => { + it('a date is its own day; an instant with no timezone is its UTC day', () => { expect(documentDateOf('2026-03-15')).toBe('2026-03-15'); expect(documentDateOf('2026-03-31T23:30:00-05:00')).toBe('2026-04-01'); expect(documentDateOf('2026-03-31T23:30:00Z')).toBe('2026-03-31'); @@ -101,12 +105,38 @@ describe('the document date', () => { expect(documentDateOf('not a date')).toBeUndefined(); expect(documentDateOf(20260315)).toBeUndefined(); }); + + it('an instant is dated in the organization\'s timezone; a calendar date is not moved by it', () => { + // 2026-03-15T17:00Z is 01:00 on the 16th in Shanghai and 13:00 on the 15th in New York. + expect(documentDateOf('2026-03-15T17:00:00Z', 'Asia/Shanghai')).toBe('2026-03-16'); + expect(documentDateOf(new Date('2026-03-15T17:00:00Z'), 'Asia/Shanghai')).toBe('2026-03-16'); + expect(documentDateOf('2026-03-16T02:00:00Z', 'America/New_York')).toBe('2026-03-15'); + expect(documentDateOf('2026-03-15T17:00:00Z', 'UTC')).toBe('2026-03-15'); + expect(documentDateOf('2026-03-15', 'Asia/Shanghai')).toBe('2026-03-15'); + // A zone `Intl` does not know reads UTC. + expect(documentDateOf('2026-03-15T17:00:00Z', 'Mars/Olympus')).toBe('2026-03-15'); + }); + + it('a datetime rateDate asks the timezone and picks the rate of the local day; a date rateDate never asks it', async () => { + const datetimePlan = planHomeAmounts({ fields: { ...SCHEMA.fields, invoice_date: { type: 'datetime' } } }); + expect(datetimePlan[0]!.rateDateIsInstant).toBe(true); + const rates = { USD: [['2026-03-15', 0.1389], ['2026-03-16', 0.15]] as Array<[string, unknown]> }; + const at0100Shanghai = { currency_code: 'USD', invoice_date: '2026-03-15T17:00:00Z', amount: 15 }; + const sh = serving('CNY', rates, 'Asia/Shanghai'); + expect(await projectHomeAmounts(datetimePlan, at0100Shanghai, sh.s, write)).toEqual({ amount_home: 100, amount_rate: 0.15 }); + expect(sh.reads.timezone).toBe(1); + const utc = serving('CNY', rates, undefined); + expect(await projectHomeAmounts(datetimePlan, at0100Shanghai, utc.s, write)).toEqual({ amount_home: 107.99, amount_rate: 0.1389 }); + const dated = serving('CNY', rates, 'Asia/Shanghai'); + expect(await projectHomeAmounts(PLAN, { ...at0100Shanghai, invoice_date: '2026-03-15' }, dated.s, write)).toEqual({ amount_home: 107.99, amount_rate: 0.1389 }); + expect(dated.reads.timezone).toBe(0); + }); }); describe('the plan', () => { it('reads only a record-mode amount declaring homeAmount', () => { expect(PLAN).toEqual([{ - amountField: 'amount', codeField: 'currency_code', homeField: 'amount_home', rateDateField: 'invoice_date', rateField: 'amount_rate', + amountField: 'amount', codeField: 'currency_code', homeField: 'amount_home', rateDateField: 'invoice_date', rateDateIsInstant: false, rateField: 'amount_rate', }]); expect(planHomeAmounts({ fields: { a: { type: 'currency' } } })).toEqual([]); expect(planHomeAmounts(undefined)).toEqual([]); @@ -174,7 +204,7 @@ describe('one projection', () => { const { s, reads } = serving(undefined); expect(await projectHomeAmounts(PLAN, { amount: null, currency_code: 'EUR' }, s, write)) .toEqual({ amount_home: null, amount_rate: null }); - expect(reads).toEqual({ home: 0, rate: 0 }); + expect(reads).toEqual({ home: 0, rate: 0, timezone: 0 }); }); it('no home currency refuses ERR_HOME_CURRENCY_UNSET, naming the setting — never a default', async () => { @@ -209,13 +239,14 @@ describe('one projection', () => { expect(reads.rate).toBe(0); }); - it('a memoized serving reads each home currency and each (currency, date) rate once', async () => { - const { s, reads } = serving('USD', { EUR: [['2026-01-01', 0.9]] }); + it('a memoized serving reads each home currency, timezone and (currency, date) rate once', async () => { + const { s, reads } = serving('USD', { EUR: [['2026-01-01', 0.9]] }, 'Asia/Shanghai'); const m = memoizeHomeAmountServing(s); + const datetimePlan = planHomeAmounts({ fields: { ...SCHEMA.fields, invoice_date: { type: 'datetime' } } }); for (let i = 0; i < 5; i++) { - await projectHomeAmounts(PLAN, { amount: i + 1, currency_code: 'EUR', invoice_date: '2026-02-01' }, m, write); + await projectHomeAmounts(datetimePlan, { amount: i + 1, currency_code: 'EUR', invoice_date: '2026-02-01T10:00:00Z' }, m, write); } - expect(reads).toEqual({ home: 1, rate: 1 }); + expect(reads).toEqual({ home: 1, rate: 1, timezone: 1 }); }); }); diff --git a/packages/objectql/src/home-amount-projection.ts b/packages/objectql/src/home-amount-projection.ts index 5274d08d5e9..61216f0c62f 100644 --- a/packages/objectql/src/home-amount-projection.ts +++ b/packages/objectql/src/home-amount-projection.ts @@ -83,6 +83,12 @@ export interface HomeAmountPlanEntry { readonly homeField: string; /** `homeAmount.rateDate`: the document date that picks the rate. */ readonly rateDateField: string; + /** + * The document date is a `datetime` field: an instant, whose calendar day is + * taken in the organization's `localization.timezone`. A `date` field's value + * is its own calendar day. + */ + readonly rateDateIsInstant: boolean; /** `homeAmount.rateField`: where the rate used is stored. */ readonly rateField: string; } @@ -105,7 +111,8 @@ export function planHomeAmounts(schema: unknown): HomeAmountPlanEntry[] { if (home === null || typeof home !== 'object') continue; const { field, rateDate, rateField } = home as { field?: unknown; rateDate?: unknown; rateField?: unknown }; if (typeof field !== 'string' || typeof rateDate !== 'string' || typeof rateField !== 'string') continue; - plan.push({ amountField, codeField, homeField: field, rateDateField: rateDate, rateField }); + const rateDateType = ((fields as Record)[rateDate] as { type?: unknown } | undefined)?.type; + plan.push({ amountField, codeField, homeField: field, rateDateField: rateDate, rateDateIsInstant: rateDateType === 'datetime', rateField }); } return plan; } @@ -221,19 +228,44 @@ const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; /** * The calendar date a document-date value names, as `YYYY-MM-DD`, or - * `undefined` when it names none. A `date` value is its own calendar date. A - * `datetime` value is an instant, and its calendar date is taken in UTC — the - * one reading that gives every write path (a REST write, an import, a flow) - * the same rate for the same stored instant, whatever zone the writer's - * request carried. + * `undefined` when it names none. A `date` value is its own calendar date, and + * so is a bare `YYYY-MM-DD` spelling of any value. An instant (a `datetime` + * value) is given `timezone`, the organization's `localization.timezone` + * (IANA), and its calendar date is taken there (ruling 6539345871, letter B): + * an invoice written at 01:00 in Shanghai is dated that Shanghai day. It is + * the organization's setting and never the writer's request zone, so every + * write path (a REST write, an import, a flow) picks the same rate for the + * same stored instant. No timezone, or one `Intl` does not know, reads UTC, + * the `localization.timezone` default — the `autonumber-format` reading. */ -export function documentDateOf(value: unknown): string | undefined { - if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10); +export function documentDateOf(value: unknown, timezone?: string): string | undefined { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : calendarDayIn(value, timezone); if (typeof value !== 'string') return undefined; const text = value.trim(); if (CALENDAR_DATE.test(text)) return text; const ms = Date.parse(text); - return Number.isNaN(ms) ? undefined : new Date(ms).toISOString().slice(0, 10); + return Number.isNaN(ms) ? undefined : calendarDayIn(new Date(ms), timezone); +} + +/** The `YYYY-MM-DD` calendar day of `instant` in `timezone` (IANA), UTC when absent or unknown. */ +function calendarDayIn(instant: Date, timezone: string | undefined): string { + if (timezone && timezone !== 'UTC') { + try { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(instant); + const y = parts.find((p) => p.type === 'year')?.value; + const m = parts.find((p) => p.type === 'month')?.value; + const d = parts.find((p) => p.type === 'day')?.value; + if (y && m && d) return `${y}-${m}-${d}`; + } catch { + // An unknown zone reads UTC, below. + } + } + return instant.toISOString().slice(0, 10); } // ── What the projection reads ───────────────────────────────────────────── @@ -252,13 +284,27 @@ export interface ExchangeRateRow { export interface HomeAmountServing { /** The organization's `localization.currency`, or `undefined` when unset. */ homeCurrency(organizationId: string | undefined): Promise; + /** The organization's `localization.timezone` (IANA), or `undefined` when unset (read as UTC). */ + timezone(organizationId: string | undefined): Promise; /** The `currency` row with the latest `effective_date` on or before `onOrBefore`, or `undefined`. */ rateOn(organizationId: string | undefined, currency: string, onOrBefore: string): Promise; } -/** Wrap a serving so each (organization, currency, date) and each organization's home currency is read once. */ +/** An organization's two localization settings the projection reads; each `undefined` when unset. */ +export interface HomeLocalization { + /** `localization.currency`: the home currency. */ + readonly currency?: string; + /** `localization.timezone` (IANA): the zone a `datetime` document date is dated in. */ + readonly timezone?: string; +} + +/** Reads an organization's {@link HomeLocalization}; the engine's host bridge to the settings service. */ +export type HomeLocalizationResolver = (organizationId: string | undefined) => Promise; + +/** Wrap a serving so each (organization, currency, date) and each organization's home currency and timezone are read once. */ export function memoizeHomeAmountServing(serving: HomeAmountServing): HomeAmountServing { const homes = new Map>(); + const zones = new Map>(); const rates = new Map>(); return { homeCurrency(organizationId) { @@ -270,6 +316,15 @@ export function memoizeHomeAmountServing(serving: HomeAmountServing): HomeAmount } return hit; }, + timezone(organizationId) { + const key = organizationId ?? ''; + let hit = zones.get(key); + if (!hit) { + hit = serving.timezone(organizationId); + zones.set(key, hit); + } + return hit; + }, rateOn(organizationId, currency, onOrBefore) { const key = `${organizationId ?? ''}\u0000${currency}\u0000${onOrBefore}`; let hit = rates.get(key); @@ -441,7 +496,7 @@ export async function projectHomeAmounts( + `currency ${home}: the exchange rate is the one in force on this date`, }]); } - const date = documentDateOf(dateValue); + const date = documentDateOf(dateValue, entry.rateDateIsInstant ? await serving.timezone(write.organizationId) : undefined); if (date === undefined) continue; const row = await serving.rateOn(write.organizationId, code, date); diff --git a/packages/objectql/src/home-amount-write-paths.test.ts b/packages/objectql/src/home-amount-write-paths.test.ts index 4726917aded..db0b5b1165c 100644 --- a/packages/objectql/src/home-amount-write-paths.test.ts +++ b/packages/objectql/src/home-amount-write-paths.test.ts @@ -71,12 +71,15 @@ describe('import and REST writes convert through the same engine path', () => { let objectql: ObjectQL; let stub: ReturnType; /** What the settings service was asked, and what it answers. */ - let settingsAsked: Array<{ ns: string; key: string; tenantId?: unknown }>; + let settingsAsked: Array<{ ns: string; key: string | string[]; tenantId?: unknown }>; let home: string | undefined; + let timezone: string | undefined; - async function bootKernel(withSettings: boolean) { + /** `grouped`: the settings service offers `getMany` (the real one does); otherwise `get` alone. */ + async function bootKernel(withSettings: boolean, grouped = true) { settingsAsked = []; home = 'CNY'; + timezone = undefined; kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); stub = makeStubDriver(); await kernel.use({ @@ -87,11 +90,18 @@ describe('import and REST writes convert through the same engine path', () => { await kernel.use({ name: 'fake-settings', type: 'standard', version: '1.0.0', init: async (ctx: any) => { + const value = (key: string) => (key === 'currency' ? home : key === 'timezone' ? timezone : undefined); ctx.registerService('settings', { async get(ns: string, key: string, c?: { tenantId?: unknown }) { settingsAsked.push({ ns, key, tenantId: c?.tenantId }); - return { value: home }; + return { value: value(key) }; }, + ...(grouped ? { + async getMany(ns: string, keys: string[], c?: { tenantId?: unknown }) { + settingsAsked.push({ ns, key: keys, tenantId: c?.tenantId }); + return Object.fromEntries(keys.map((k) => [k, { value: value(k) }])); + }, + } : {}), }); }, } as any); @@ -142,8 +152,10 @@ describe('import and REST writes convert through the same engine path', () => { expect(storedByRef('rest')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); await p.updateData({ object: OBJECT, id: storedByRef('rest').id, data: { amount: 1389 }, context: { userId: 'u1', tenantId: 'org_a' } }); expect(storedByRef('rest')).toMatchObject({ amount_home: 10000, amount_rate: 0.1389 }); - // The home currency was asked of the settings service, for the row's organization. - expect(settingsAsked).toContainEqual({ ns: 'localization', key: 'currency', tenantId: 'org_a' }); + // The home currency and timezone were asked of the settings service in + // one grouped read, for the row's organization. + expect(settingsAsked).toContainEqual({ ns: 'localization', key: ['currency', 'timezone'], tenantId: 'org_a' }); + expect(settingsAsked.every((a) => Array.isArray(a.key))).toBe(true); }); for (const [label, p] of [['batched (insertManyData)', protocol], ['per-row (createData)', perRow]] as const) { @@ -170,6 +182,19 @@ describe('import and REST writes convert through the same engine path', () => { expect(storedByRef('h')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); }); + it('a datetime document date is dated in the organization\'s localization.timezone through the REST door', async () => { + timezone = 'Asia/Shanghai'; + objectql.registry.registerObject({ + name: 'fx_order', label: 'FX order', datasource: 'fx-store', + fields: { ...FIELDS, invoice_date: { name: 'invoice_date', type: 'datetime' } }, + } as any, 'test', 'test'); + stub.storeFor('sys_exchange_rate').set('usd_0316', { id: 'usd_0316', currency: 'USD', effective_date: '2026-03-16', rate: 0.15 }); + // 2026-03-15T17:00Z is 01:00 on the 16th in Shanghai. + const created = await protocol().createData({ object: 'fx_order', data: { ref: 'o', currency_code: 'USD', invoice_date: '2026-03-15T17:00:00Z', amount: 1500 }, context: { userId: 'u1', tenantId: 'org_a' } }); + const stored = Array.from(stub.storeFor('fx_order').values()).find((r) => r.ref === 'o'); + expect(stored, JSON.stringify(created)).toMatchObject({ amount_home: 10000, amount_rate: 0.15 }); + }); + it('an unset localization.currency refuses the REST write ERR_HOME_CURRENCY_UNSET', async () => { home = undefined; const err = await protocol().createData({ object: OBJECT, data: { ref: 'x', currency_code: 'USD', invoice_date: '2026-03-15', amount: 1 }, context: { userId: 'u1' } }) @@ -179,6 +204,16 @@ describe('import and REST writes convert through the same engine path', () => { }); }); + it('a settings service without getMany is asked key by key, and converts alike', async () => { + await bootKernel(true, false); + await protocol().createData({ object: OBJECT, data: { ref: 'k', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, context: { userId: 'u1', tenantId: 'org_a' } }); + expect(storedByRef('k')).toMatchObject({ amount_home: 71994.24, amount_rate: 0.1389 }); + expect(settingsAsked).toEqual([ + { ns: 'localization', key: 'currency', tenantId: 'org_a' }, + { ns: 'localization', key: 'timezone', tenantId: 'org_a' }, + ]); + }); + it('with no settings service composed, the home currency is unset — refused, never defaulted', async () => { await bootKernel(false); const err = await protocol().createData({ object: OBJECT, data: { ref: 'x', currency_code: 'CNY', amount: 1 }, context: { userId: 'u1' } }) diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index e1a24163c47..18fc75b5c3d 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -1341,28 +1341,49 @@ export class ObjectQLPlugin implements Plugin { }); } - // [ADR-0144 D4, Open item 4] The home currency the write-time home - // projection converts into: the organization's `localization.currency` - // setting. The settings service is resolved when a write asks, not - // here, so plugin order cannot leave the projection without it. No - // settings service, or no `localization` manifest registered, is an - // unset home currency — refused, never defaulted; a read that FAILS is - // the write's failure, not an unset setting. - this.ql.setHomeCurrencyResolver(async (organizationId) => { - let settings: { get?: (ns: string, key: string, c?: Record) => Promise<{ value?: unknown } | undefined> } | undefined; + // [ADR-0144 D4, Open item 4] The localization the write-time home + // projection reads: the organization's `localization.currency` (the + // home currency it converts into) and `localization.timezone` (the + // zone a `datetime` document date is dated in; ruling 6539345871, + // letter B), in one grouped `getMany` read. The settings service is + // resolved when a write asks, not here, so plugin order cannot leave + // the projection without it. No settings service, or a `localization` + // namespace or key not registered, is an unset setting — an unset home + // currency is refused, never defaulted, and an unset timezone reads + // UTC; a read that FAILS is the write's failure, not an unset setting. + const isUnregisteredSetting = (e: any) => e?.code === 'SETTINGS_UNKNOWN_NAMESPACE' || e?.code === 'SETTINGS_UNKNOWN_KEY'; + const settingText = (resolved: { value?: unknown } | undefined) => (typeof resolved?.value === 'string' ? resolved.value : undefined); + this.ql.setHomeLocalizationResolver(async (organizationId) => { + let settings: { + get?: (ns: string, key: string, c?: Record) => Promise<{ value?: unknown } | undefined>; + getMany?: (ns: string, keys: string[], c?: Record) => Promise>; + } | undefined; try { settings = ctx.getService('settings') as typeof settings; } catch { return undefined; } if (!settings || typeof settings.get !== 'function') return undefined; - try { - const resolved = await settings.get('localization', 'currency', organizationId !== undefined ? { tenantId: organizationId } : {}); - return typeof resolved?.value === 'string' ? resolved.value : undefined; - } catch (e: any) { - if (e?.code === 'SETTINGS_UNKNOWN_NAMESPACE' || e?.code === 'SETTINGS_UNKNOWN_KEY') return undefined; - throw e; + const scope = organizationId !== undefined ? { tenantId: organizationId } : {}; + if (typeof settings.getMany === 'function') { + try { + const many = await settings.getMany('localization', ['currency', 'timezone'], scope); + return { currency: settingText(many?.currency), timezone: settingText(many?.timezone) }; + } catch (e: any) { + // `getMany` is all-or-nothing: one unregistered key refuses + // both, so each is asked on its own below. + if (!isUnregisteredSetting(e)) throw e; + } } + const one = async (key: string) => { + try { + return settingText(await settings!.get!('localization', key, scope)); + } catch (e: any) { + if (isUnregisteredSetting(e)) return undefined; + throw e; + } + }; + return { currency: await one('currency'), timezone: await one('timezone') }; }); } diff --git a/packages/qa/dogfood/test/home-amount-projection.test.ts b/packages/qa/dogfood/test/home-amount-projection.test.ts index f96fc079889..666bd1d3233 100644 --- a/packages/qa/dogfood/test/home-amount-projection.test.ts +++ b/packages/qa/dogfood/test/home-amount-projection.test.ts @@ -99,6 +99,14 @@ const FX_INVOICE = { }, }; +/** The same amount, dated by a `datetime`: its calendar day is taken in the organization's timezone. */ +const FX_ORDER = { + ...FX_INVOICE, + name: 'fx_order', + label: 'FX order', + fields: { ...FX_INVOICE.fields, invoice_date: { name: 'invoice_date', type: 'datetime' } }, +}; + const appWith = (objects: unknown[]) => ({ id: 'com.objectstack.dogfood.home-amount-projection', name: 'Home amount projection', @@ -125,16 +133,17 @@ for (const cell of CELLS) { let driver: SqlDriver; let engine: ObjectQL; let home = 'CNY'; + let timezone: string | undefined; beforeAll(async () => { driver = await cell.make(); engine = new ObjectQL(); engine.registerDriver(driver, true); await engine.init(); - engine.registerApp(appWith([SysExchangeRate, FX_INVOICE])); + engine.registerApp(appWith([SysExchangeRate, FX_INVOICE, FX_ORDER])); await engine.syncSchemas(); await installDefaultOrganization(engine); - engine.setHomeCurrencyResolver(async () => home); + engine.setHomeLocalizationResolver(async () => ({ currency: home, timezone })); for (const [currency, effective_date, rate] of RATES) { await engine.insert('sys_exchange_rate', { currency, effective_date, rate, source: 'manual' }, SYS); } @@ -227,6 +236,29 @@ for (const cell of CELLS) { } }); + it('a datetime document date is dated in localization.timezone, through the column and back', async () => { + home = 'CNY'; + // 2026-03-31T17:00Z is 01:00 on 2026-04-01 in Shanghai; USD has a new rate from April 1. + const at0100Shanghai = '2026-03-31T17:00:00Z'; + const readOrder = async (id: unknown) => { + const back = await engine.findOne('fx_order', { where: { id }, context: SYS.context } as never) as Record; + return { home: Number(back.amount_home), rate: Number(back.amount_rate) }; + }; + try { + timezone = 'Asia/Shanghai'; + const sh = await engine.insert('fx_order', { name: 'sh', currency_code: 'USD', invoice_date: at0100Shanghai, amount: 1500 }, SYS); + expect(await readOrder(sh.id)).toEqual({ home: 10000, rate: 0.15 }); + // An amount edit reads the stored datetime back from the column and dates it alike. + await engine.update('fx_order', { id: sh.id, amount: 3000 }, SYS); + expect(await readOrder(sh.id)).toEqual({ home: 20000, rate: 0.15 }); + timezone = undefined; + const utc = await engine.insert('fx_order', { name: 'utc', currency_code: 'USD', invoice_date: at0100Shanghai, amount: 1389 }, SYS); + expect(await readOrder(utc.id)).toEqual({ home: 10000, rate: 0.1389 }); + } finally { + timezone = undefined; + } + }); + it('an update of the amount, the code or the date converts again; another edit does not', async () => { home = 'CNY'; const { id } = await engine.insert('fx_invoice', { name: 'u', currency_code: 'USD', invoice_date: '2026-03-15', amount: 10000 }, SYS);