From 777793d8dc5c865e2d966bd04743bc0afda3136e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:31:39 +0000 Subject: [PATCH 1/8] wip(spec): a formula field declares a currency result (#22727) Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- packages/spec/src/data/display-name.ts | 2 +- packages/spec/src/data/field.form.ts | 15 ++- packages/spec/src/data/field.zod.ts | 23 +++- ...er-boolean-comparand-declared-type.test.ts | 6 +- ...ter-number-comparand-declared-type.test.ts | 10 +- .../filter-number-comparand-declared-type.ts | 5 +- ...filter-text-operator-declared-type.test.ts | 11 +- .../filter-text-operator-declared-type.ts | 10 +- .../src/data/form-return-type-options.test.ts | 29 ++++- .../src/data/formula-currency-result.test.ts | 112 ++++++++++++++++++ 10 files changed, 194 insertions(+), 29 deletions(-) create mode 100644 packages/spec/src/data/formula-currency-result.test.ts diff --git a/packages/spec/src/data/display-name.ts b/packages/spec/src/data/display-name.ts index ba635e0ea21..7f707876b3e 100644 --- a/packages/spec/src/data/display-name.ts +++ b/packages/spec/src/data/display-name.ts @@ -36,7 +36,7 @@ export interface TitleEligibleFieldDef { type?: string; /** * For `formula` fields: the declared result type. The framework field schema - * carries this as `returnType` (number/text/boolean/date); `valueType` is also + * carries this as `returnType` (number/text/boolean/date/currency); `valueType` is also * accepted for forward/cross-repo compatibility. A formula is title-eligible * ONLY when this is `text`. */ diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index b719b0797ef..f688615d6d3 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -99,11 +99,14 @@ export const fieldForm = defineForm({ // reach the translation catalogs (a schema-derived sub-field never does). // Schema defaults (`dynamic`, `CNY`) are placeholders the renderer never // writes on mount, so an untouched composite stays absent. + // #22727 — a formula returning `currency` carries its currency in this + // same key, so the row shows for it too; the gate is the returnType + // row's own value, not a second control. { field: 'currencyConfig', type: 'composite', - visibleWhen: "data.type == 'currency'", - helpText: 'Which currency this field is in. Unset: dynamic mode. The stored value is a bare number in either mode.', + visibleWhen: "data.type == 'currency' || (data.type == 'formula' && data.returnType == 'currency')", + helpText: 'Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in either mode.', fields: [ { field: 'currencyMode', type: 'select', helpText: 'dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency.', options: [ { label: 'Dynamic — the tenant default currency', value: 'dynamic' }, @@ -309,14 +312,16 @@ export const fieldForm = defineForm({ collapsed: true, fields: [ { field: 'expression', widget: 'textarea', helpText: 'CEL expression to calculate this field (makes it read-only)' }, - // The four members are what FieldSchema declares — an explicit list, not + // The five members are what FieldSchema declares — an explicit list, not // the derived enum, so this row cannot pick up a member the formula - // return type does not have. - { field: 'returnType', type: 'select', visibleWhen: "data.type == 'formula'", helpText: 'Declared value type of the formula, stamped from the inferred CEL type. Consumers read it instead of re-parsing the expression.', options: [ + // return type does not have. `currency` joined with #22727; its currency + // is the `currencyConfig` row above, shown for a currency formula too. + { field: 'returnType', type: 'select', visibleWhen: "data.type == 'formula'", helpText: 'Declared value type of the formula, stamped from the inferred CEL type. Consumers read it instead of re-parsing the expression. Currency: an amount of money, in the currency the Currency Config of this field declares (dynamic when unset).', options: [ { label: 'Text', value: 'text' }, { label: 'Number', value: 'number' }, { label: 'Boolean', value: 'boolean' }, { label: 'Date', value: 'date' }, + { label: 'Currency', value: 'currency' }, ] }, { field: 'summaryOperations', diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index cd4528f73f3..c06f28ed992 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -489,8 +489,9 @@ const CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE: Readonly> /** * Currency Configuration Schema - * Configuration for currency field type supporting multi-currency - * + * Configuration for currency field type supporting multi-currency — and, in the + * same shape, for a `formula` field whose `returnType` is `currency` (#22727). + * * Note: Currency codes are validated by length only (3 characters) to support: * - Standard ISO 4217 codes (USD, EUR, CNY, etc.) * - Cryptocurrency codes (BTC, ETH, etc.) @@ -1713,9 +1714,18 @@ export const FieldSchema = lazySchema(() => { * measures, display formatting, validation — read a declared type instead of * re-parsing the expression. Authoring stamps it from the inferred CEL type; * absent when the type can't be proven (an ambiguous/`dyn` expression). + * + * `currency` (#22727): the result is an amount of money. It carries its + * currency exactly the way a `currency` field does — through the formula's + * OWN `currencyConfig`, the same schema and the same defaults (absent ⇒ + * `dynamic`, the tenant default currency). Nothing is read from the fields the + * expression references at read time: "taken from the source field" happens + * at AUTHORING, where `@objectstack/formula`'s `inferFormulaReturn` proves the + * result is money and answers the source's currency for the stamp to copy. + * The value itself is a bare number, as a currency field's is. */ - returnType: z.enum(['number', 'text', 'boolean', 'date']).optional() - .describe('Inferred value type of a formula field (number/text/boolean/date)'), + returnType: z.enum(['number', 'text', 'boolean', 'date', 'currency']).optional() + .describe('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: strictObject({ surface: 'this roll-up summary', history: FIELD_HISTORY, @@ -1771,8 +1781,9 @@ export const FieldSchema = lazySchema(() => { // comparison. See docs/audits/2026-06-dead-surface-disposition-plan.md (P2 field prune). step: z.number().optional().describe('Step increment for slider (default: 1)'), - // Currency field config - currencyConfig: CurrencyConfigSchema.optional().describe('Configuration for currency field type'), + // Currency config — read for a `currency` field and for a `formula` whose + // `returnType` is `currency` (#22727): one shape for both, never a second one. + currencyConfig: CurrencyConfigSchema.optional().describe('Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode.'), // Vector field — flat dimensionality (the live authoring path). // The renderer reads this flat sibling (objectui VectorField.tsx:11). diff --git a/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts b/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts index 138a3dd0bda..c6ec08d65e0 100644 --- a/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts +++ b/packages/spec/src/data/filter-boolean-comparand-declared-type.test.ts @@ -38,6 +38,7 @@ import { type BooleanComparandDoorNarrowsCase, type BooleanComparandDoorRefusalCase, } from './filter-boolean-comparand-declared-type'; +import { FORMULA_RETURN_TYPE_AS_FIELD_TYPE } from './filter-text-operator-declared-type'; import { StandardErrorCode } from '../api/errors.zod'; const isRefusal = (c: BooleanComparandDoorCase): c is BooleanComparandDoorRefusalCase => c.verdict === 'door-refusal'; @@ -108,6 +109,8 @@ describe('the judged fields and positions', () => { it('judges a formula by its returnType, and defers on an unreadable one', () => { expect(booleanComparandFieldVerdict({ type: 'formula', returnType: 'boolean' })).toBe('judged'); expect(booleanComparandFieldVerdict({ type: 'formula', returnType: 'text' })).toBe('not-judged'); + // #22727 — a currency formula is the number door's subject, never this one's. + expect(booleanComparandFieldVerdict({ type: 'formula', returnType: 'currency' })).toBe('not-judged'); expect(booleanComparandFieldVerdict({ type: 'formula' })).toBe('deferred'); expect(booleanComparandFieldVerdict({ type: 'number' })).toBe('not-judged'); }); @@ -120,7 +123,8 @@ describe('the judged fields and positions', () => { it('judges a field class disjoint from the number door\'s — at most one arm judges a key', () => { const metas = [ ...FieldType.options.map((type) => ({ type })), - ...['number', 'text', 'boolean', 'date'].map((returnType) => ({ type: 'formula', returnType })), + // Every declared return type, read off the shared map — #22727's `currency` included. + ...[...FORMULA_RETURN_TYPE_AS_FIELD_TYPE.keys()].map((returnType) => ({ type: 'formula', returnType })), ]; for (const meta of metas) { const booleanJudges = booleanComparandFieldVerdict(meta) === 'judged'; diff --git a/packages/spec/src/data/filter-number-comparand-declared-type.test.ts b/packages/spec/src/data/filter-number-comparand-declared-type.test.ts index 0c921065ca4..73dc3534932 100644 --- a/packages/spec/src/data/filter-number-comparand-declared-type.test.ts +++ b/packages/spec/src/data/filter-number-comparand-declared-type.test.ts @@ -139,6 +139,8 @@ describe('the judged fields', () => { expect(numberComparandFieldVerdict({ type: t }), t).toBe(NUMERIC_VALUE_TYPES.has(t) ? 'judged' : 'not-judged'); } expect(numberComparandFieldVerdict({ type: 'formula', returnType: 'number' })).toBe('judged'); + // #22727 — a currency formula is judged as the currency field it reads like. + expect(numberComparandFieldVerdict({ type: 'formula', returnType: 'currency' })).toBe('judged'); for (const rt of ['text', 'boolean', 'date']) { expect(numberComparandFieldVerdict({ type: 'formula', returnType: rt }), rt).toBe('not-judged'); } @@ -380,13 +382,13 @@ describe('numberComparandRefusalMessage', () => { // ── The fixture ────────────────────────────────────────────────────────────── describe('the fixture', () => { - it('carries one field per FieldType member (f_), four typed formulas and one untyped', () => { + it('carries one field per FieldType member (f_), five typed formulas and one untyped', () => { const names = NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS.map((f) => f.name); expect(new Set(names).size).toBe(names.length); const plain = NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS.filter((f) => f.type !== 'formula').map((f) => f.type); expect(sorted(plain)).toEqual(sorted(FieldType.options.filter((t) => t !== 'formula'))); const formulas = NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS.filter((f) => f.type === 'formula'); - expect(sorted(formulas.map((f) => f.returnType ?? '(none)'))).toEqual(['(none)', 'boolean', 'date', 'number', 'text']); + expect(sorted(formulas.map((f) => f.returnType ?? '(none)'))).toEqual(['(none)', 'boolean', 'currency', 'date', 'number', 'text']); }); it('every field is a legal FieldSchema input, and the object a legal ObjectSchema input', () => { @@ -459,11 +461,11 @@ describe('NUMBER_COMPARAND_DOOR_CASES', () => { } }); - it('the census covers every fixture field, refusing exactly the numeric class and formula returning number', () => { + it('the census covers every fixture field, refusing exactly the numeric class and formulas returning number or currency', () => { const census = NUMBER_COMPARAND_DOOR_CASES.filter((c) => c.name.startsWith('[census]')); expect(sorted(census.map((c) => c.key))).toEqual(sorted(NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS.map((f) => f.name))); expect(sorted(census.filter(isRefusal).map((c) => c.key))) - .toEqual(sorted([...[...NUMERIC_VALUE_TYPES].map((t) => `f_${t}`), 'f_formula_number'])); + .toEqual(sorted([...[...NUMERIC_VALUE_TYPES].map((t) => `f_${t}`), 'f_formula_number', 'f_formula_currency'])); expect(census.filter((c) => c.verdict === 'deferred').map((c) => c.key)).toEqual(['f_formula_untyped']); expect(census.filter(isNarrows)).toEqual([]); }); diff --git a/packages/spec/src/data/filter-number-comparand-declared-type.ts b/packages/spec/src/data/filter-number-comparand-declared-type.ts index e5f33fdc841..f66faa4f395 100644 --- a/packages/spec/src/data/filter-number-comparand-declared-type.ts +++ b/packages/spec/src/data/filter-number-comparand-declared-type.ts @@ -250,6 +250,7 @@ */ import type { FilterCondition } from './filter.zod'; +import type { Field } from './field.zod'; import { BOOLEAN_VALUE_TYPES, CALENDAR_DATE_TYPES, @@ -676,7 +677,7 @@ export interface NumberComparandDoorFixtureField { /** `formula` — a CEL expression, present so the field is a legal declaration. */ readonly expression?: string; /** `formula` — the declared return type under test, or absent for the deferred row. */ - readonly returnType?: 'number' | 'text' | 'boolean' | 'date'; + readonly returnType?: NonNullable; /** `summary` — a roll-up declaration, present so the field is a legal declaration. */ readonly summaryOperations?: { readonly object: string; readonly field: string; readonly function: 'count' }; /** Single-choice types (`select` / `radio`) — one option, present so the field is a legal declaration. */ @@ -729,7 +730,7 @@ export const NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS: readonly NumberComparandDoorF name: `f_formula_${returnType}`, type: 'formula', expression: '1', - returnType: returnType as 'number' | 'text' | 'boolean' | 'date', + returnType: returnType as NonNullable, })), { name: 'f_formula_untyped', type: 'formula', expression: '1' }, ]; diff --git a/packages/spec/src/data/filter-text-operator-declared-type.test.ts b/packages/spec/src/data/filter-text-operator-declared-type.test.ts index c549a428bf8..dc35088f68e 100644 --- a/packages/spec/src/data/filter-text-operator-declared-type.test.ts +++ b/packages/spec/src/data/filter-text-operator-declared-type.test.ts @@ -187,11 +187,14 @@ describe('textOperatorDoorVerdict — a text operator over a type that never sto for (const t of TEXT_OPERATOR_DOOR_PASSING_TYPES) expect(textOperatorDoorVerdict({ type: t }), t).toBe('passes'); }); - it('H3: judges a formula as the FieldType its returnType names — text passes, number/boolean/date are refused, absent is deferred', () => { + it('H3: judges a formula as the FieldType its returnType names — text passes, number/boolean/date/currency are refused, absent is deferred', () => { expect(textOperatorDoorVerdict({ type: 'formula', returnType: 'text' })).toBe('passes'); expect(textOperatorDoorVerdict({ type: 'formula', returnType: 'number' })).toBe('door-refusal'); expect(textOperatorDoorVerdict({ type: 'formula', returnType: 'boolean' })).toBe('door-refusal'); expect(textOperatorDoorVerdict({ type: 'formula', returnType: 'date' })).toBe('door-refusal'); + // #22727 — a currency formula reads like the currency field it is judged as. + expect(textOperatorDoorVerdict({ type: 'formula', returnType: 'currency' })).toBe('door-refusal'); + expect(textOperatorDoorVerdict({ type: 'formula', returnType: 'currency' })).toBe(textOperatorDoorVerdict({ type: 'currency' })); expect(textOperatorDoorVerdict({ type: 'formula' })).toBe('deferred'); expect(textOperatorDoorVerdict({ type: 'formula', returnType: undefined })).toBe('deferred'); // A spelling the schema does not declare is unreadable too — never a silent pass. @@ -212,7 +215,7 @@ describe('textOperatorDoorVerdict — a text operator over a type that never sto // ── The fixture ────────────────────────────────────────────────────────────── describe('the fixture', () => { - it('carries one field per FieldType member (f_), four typed formulas and one untyped', () => { + it('carries one field per FieldType member (f_), five typed formulas and one untyped', () => { const names = TEXT_OPERATOR_DOOR_FIXTURE_FIELDS.map((f) => f.name); expect(new Set(names).size).toBe(names.length); for (const t of FieldType.options) { @@ -221,8 +224,8 @@ describe('the fixture', () => { } const formulas = TEXT_OPERATOR_DOOR_FIXTURE_FIELDS.filter((f) => f.type === 'formula'); expect(sorted(formulas.map((f) => f.returnType ?? '(none)'))) - .toEqual(['(none)', 'boolean', 'date', 'number', 'text']); - expect(TEXT_OPERATOR_DOOR_FIXTURE_FIELDS).toHaveLength(FieldType.options.length - 1 + 5); + .toEqual(['(none)', 'boolean', 'currency', 'date', 'number', 'text']); + expect(TEXT_OPERATOR_DOOR_FIXTURE_FIELDS).toHaveLength(FieldType.options.length - 1 + 6); }); it('every field is a legal FieldSchema input, and the object a legal ObjectSchema input', () => { diff --git a/packages/spec/src/data/filter-text-operator-declared-type.ts b/packages/spec/src/data/filter-text-operator-declared-type.ts index ca54e135088..a6d32576eaa 100644 --- a/packages/spec/src/data/filter-text-operator-declared-type.ts +++ b/packages/spec/src/data/filter-text-operator-declared-type.ts @@ -162,6 +162,7 @@ */ import type { FilterCondition } from './filter.zod'; +import type { Field } from './field.zod'; import { BOOLEAN_VALUE_TYPES, CALENDAR_DATE_TYPES, @@ -240,12 +241,17 @@ export const TEXT_OPERATOR_DOOR_PASSING_TYPES: ReadonlySet = new Set([ * judges it. Every value of `FieldSchema.returnType` IS a `FieldType` member, * so the map is the identity over that enum — spelled out so an enum value * added later is a loud pin failure rather than a silent `passes`. + * + * `currency` (#22727) is judged as the `currency` field it reads like: a member + * of the numeric class, so this door refuses a text operator on it and the + * number door judges its comparands, exactly as for a `currency` field. */ export const FORMULA_RETURN_TYPE_AS_FIELD_TYPE: ReadonlyMap = new Map([ ['number', 'number'], ['text', 'text'], ['boolean', 'boolean'], ['date', 'date'], + ['currency', 'currency'], ]); /** @@ -406,7 +412,7 @@ export interface TextOperatorDoorFixtureField { /** `formula` — a CEL expression, present so the field is a legal declaration. */ readonly expression?: string; /** `formula` — the declared return type under test, or absent for the deferred row. */ - readonly returnType?: 'number' | 'text' | 'boolean' | 'date'; + readonly returnType?: NonNullable; /** `summary` — a roll-up declaration, present so the field is a legal declaration. */ readonly summaryOperations?: { readonly object: string; readonly field: string; readonly function: 'count' }; /** Single-choice types (`select` / `radio`) — one option, present so the field is a legal declaration. */ @@ -446,7 +452,7 @@ export const TEXT_OPERATOR_DOOR_FIXTURE_FIELDS: readonly TextOperatorDoorFixture name: `f_formula_${returnType}`, type: 'formula', expression: '1', - returnType: returnType as 'number' | 'text' | 'boolean' | 'date', + returnType: returnType as NonNullable, })), { name: 'f_formula_untyped', type: 'formula', expression: '1' }, ]; diff --git a/packages/spec/src/data/form-return-type-options.test.ts b/packages/spec/src/data/form-return-type-options.test.ts index 7711e01bfac..f5860d031ac 100644 --- a/packages/spec/src/data/form-return-type-options.test.ts +++ b/packages/spec/src/data/form-return-type-options.test.ts @@ -39,11 +39,20 @@ * * ## What the historical-roster assertions are for * - * The two removed members are additionally pinned as REFUSED-and-UNOFFERED. + * The two removed members were additionally pinned as REFUSED-and-UNOFFERED. * That is not a duplicate of the set equality: it is the pin that fires if the * platform ever decides a formula may return a datetime or a currency. Widening * the enum is a product decision with its own card, and this is where it is * made to notice that the two forms must move in the same change. + * + * ## #22727 — the decision, for `currency` only + * + * That decision was made once, for one member: a formula may return + * `currency`, carrying its currency in its own `currencyConfig` exactly as a + * currency field does, and `@objectstack/formula`'s `inferFormulaReturn` is the + * producer that proves a result is money. So `currency` left the roster below + * and both forms offer it, moved in the same change as the enum. `datetime` + * stays refused and unoffered: nothing infers it, and it has no card. */ import { describe, it, expect } from 'vitest'; @@ -86,7 +95,13 @@ function schemaReturnTypeMembers(): string[] { * above is derived. If one of these ever becomes a declared member, the * assertions below fail and the forms are re-opened on purpose. */ -const HISTORICALLY_OFFERED_NON_MEMBERS = ['datetime', 'currency'] as const; +const HISTORICALLY_OFFERED_NON_MEMBERS = ['datetime'] as const; + +/** + * The member #22727 declared — once on the roster above, now offered by both + * forms. Literal for the same reason the roster is: it records a decision. + */ +const DECLARED_BY_22727 = 'currency' as const; // ──────────────────────────────────────────────────────────────────────────── // Locating the declarations @@ -183,13 +198,19 @@ describe('`returnType` is never offered a value the schema refuses', () => { } }); - it('offers neither of the two members the grid used to carry', () => { + it('offers no member the schema still refuses that the grid used to carry', () => { const offered = offeredValues(decls[0]); for (const value of HISTORICALLY_OFFERED_NON_MEMBERS) { expect(offered).not.toContain(value); } }); + it('offers `currency`, the member #22727 declared, and it parses', () => { + expect(offeredValues(decls[0])).toContain(DECLARED_BY_22727); + const parsed = (FieldSchema as unknown as z.ZodType).safeParse(formulaFieldWith(DECLARED_BY_22727)); + expect(parsed.success, JSON.stringify((parsed as { error?: unknown }).error)).toBe(true); + }); + it('is expressible in the form DSL as it stands — no schema extension', () => { // `defineForm` parses at module load; re-parsing makes the claim an // assertion rather than an import side effect. @@ -199,7 +220,7 @@ describe('`returnType` is never offered a value the schema refuses', () => { }); } - it('the two removed members are refused by the schema, which is why they went', () => { + it('the member still on the roster is refused by the schema, which is why it went', () => { // The justification for the narrowing, asserted rather than asserted-about. // Should the platform ever declare one of these, this fails and the two // forms are re-opened in the same change that widens the enum. diff --git a/packages/spec/src/data/formula-currency-result.test.ts b/packages/spec/src/data/formula-currency-result.test.ts new file mode 100644 index 00000000000..260dc622018 --- /dev/null +++ b/packages/spec/src/data/formula-currency-result.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22727 — a formula field declares a currency result. + * + * `FieldSchema.returnType` gains `currency`. The currency is the formula's OWN + * `currencyConfig`: the same `CurrencyConfigSchema` a `currency` field carries, + * with the same defaults (absent ⇒ `dynamic`, the tenant default currency) and + * the same refusals. One shape, never a second one — so the assertions below + * compare a currency formula against a currency field rather than restating + * what the config accepts. + * + * "Taken from the source field" is an AUTHORING act, not a read-time fallback: + * `@objectstack/formula`'s `inferFormulaReturn` proves a result is money and + * answers the source field's currency for the stamp to copy. That producer's + * pins live in its own package; this file pins the declaration it writes. + */ + +import { describe, it, expect } from 'vitest'; + +import { FieldSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; + +const FIXED_USD = { currencyMode: 'fixed', defaultCurrency: 'USD' } as const; + +function currencyFormula(extra: Record = {}): Record { + return { + name: 'weighted_amount', + label: 'Weighted Amount', + type: 'formula', + expression: 'record.amount * record.probability / 100', + returnType: 'currency', + ...extra, + }; +} + +function currencyField(extra: Record = {}): Record { + return { name: 'amount', label: 'Amount', type: 'currency', ...extra }; +} + +/** The issues of a refused parse, reduced to what an author is shown. */ +function issuesOf(input: unknown): Array<{ code: string; path: PropertyKey[]; message: string }> { + const r = FieldSchema.safeParse(input); + if (r.success) return []; + return r.error.issues.map((i) => ({ code: i.code, path: i.path, message: i.message })); +} + +describe('a formula declares a currency result', () => { + it('`returnType: currency` parses, and without a `currencyConfig` it declares none (dynamic)', () => { + const r = FieldSchema.safeParse(currencyFormula()); + expect(r.success, JSON.stringify(issuesOf(currencyFormula()))).toBe(true); + if (!r.success) return; + expect(r.data.returnType).toBe('currency'); + // Absent stays absent — exactly as on a currency field with no config. + expect(r.data.currencyConfig).toBeUndefined(); + expect(FieldSchema.parse(currencyField()).currencyConfig).toBeUndefined(); + }); + + it('carries its currency in its own `currencyConfig`, parsed exactly as a currency field\'s', () => { + for (const config of [FIXED_USD, { currencyMode: 'dynamic' }, { currencyMode: 'fixed' }, {}]) { + const asFormula = FieldSchema.parse(currencyFormula({ currencyConfig: config })); + const asField = FieldSchema.parse(currencyField({ currencyConfig: config })); + expect(asFormula.currencyConfig, JSON.stringify(config)).toEqual(asField.currencyConfig); + } + expect(FieldSchema.parse(currencyFormula({ currencyConfig: FIXED_USD })).currencyConfig) + .toEqual({ currencyMode: 'fixed', defaultCurrency: 'USD' }); + }); + + it('refuses a malformed currency the way a currency field refuses it — same code, path and words', () => { + const malformed = [ + { currencyMode: 'fixed', defaultCurrency: 'US' }, + { currencyMode: 'per_record' }, + // The removed decimal-places key keeps its tombstone answer on a formula too. + { ...FIXED_USD, precision: 2 }, + ]; + for (const config of malformed) { + const onFormula = issuesOf(currencyFormula({ currencyConfig: config })); + const onField = issuesOf(currencyField({ currencyConfig: config })); + expect(onFormula.length, JSON.stringify(config)).toBeGreaterThan(0); + expect(onFormula, JSON.stringify(config)).toEqual(onField); + } + }); + + it('a formula over a currency field declares the currency result inside a legal object', () => { + const r = ObjectSchema.safeParse({ + name: 'opportunity', + label: 'Opportunity', + fields: { + amount: currencyField({ currencyConfig: FIXED_USD }), + probability: { name: 'probability', label: 'Probability', type: 'percent' }, + weighted_amount: currencyFormula({ currencyConfig: FIXED_USD }), + }, + }); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true); + }); +}); + +describe('CONTROL — a numeric formula stays `number`', () => { + it('`returnType: number` parses unchanged, and carries no currency', () => { + const input = { ...currencyFormula(), name: 'gross_margin', returnType: 'number', expression: 'record.margin * 100' }; + const r = FieldSchema.safeParse(input); + expect(r.success, JSON.stringify(issuesOf(input))).toBe(true); + if (!r.success) return; + expect(r.data.returnType).toBe('number'); + expect(r.data.currencyConfig).toBeUndefined(); + }); + + it('the widening adds exactly `currency` — the four earlier members are still declared', () => { + const members = (FieldSchema.shape.returnType.unwrap().options as readonly string[]).slice().sort(); + expect(members).toEqual(['boolean', 'currency', 'date', 'number', 'text']); + }); +}); From 7403a31401b7f608c25e47be6c5e4afb8906feb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:32:19 +0000 Subject: [PATCH 2/8] wip(spec): count the currency formula among the text door's refusals Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .../spec/src/data/filter-text-operator-declared-type.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/data/filter-text-operator-declared-type.test.ts b/packages/spec/src/data/filter-text-operator-declared-type.test.ts index dc35088f68e..23a3251f219 100644 --- a/packages/spec/src/data/filter-text-operator-declared-type.test.ts +++ b/packages/spec/src/data/filter-text-operator-declared-type.test.ts @@ -276,7 +276,8 @@ describe('TEXT_OPERATOR_DOOR_CASES', () => { it('covers all three verdicts — a table with one answer would not need the discriminant', () => { const count = (v: string) => TEXT_OPERATOR_DOOR_CASES.filter((c) => c.verdict === v).length; - expect(count('door-refusal')).toBe((TEXT_OPERATOR_DOOR_REFUSED_TYPES.size + 3) * TEXT_FILTER_OPERATORS.length); + // `+ 4`: the formulas returning number, boolean, date and (#22727) currency. + expect(count('door-refusal')).toBe((TEXT_OPERATOR_DOOR_REFUSED_TYPES.size + 4) * TEXT_FILTER_OPERATORS.length); expect(count('passes')).toBe((TEXT_OPERATOR_DOOR_PASSING_TYPES.size + 1) * TEXT_FILTER_OPERATORS.length); expect(count('deferred')).toBe((1 + STRUCTURED_JSON_TYPES.size) * TEXT_FILTER_OPERATORS.length); }); From f5c7de4ac07775234dc79d7e0380e7183bfb49dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:35:27 +0000 Subject: [PATCH 3/8] feat(formula): inferFormulaReturn proves a money result and names its currency; forms and docs offer the currency result Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- content/docs/data-modeling/field-types.mdx | 2 +- content/docs/data-modeling/formulas.mdx | 8 +- .../docs/data-modeling/validation-rules.mdx | 2 +- packages/formula/src/formula-return.test.ts | 167 ++++++++++ packages/formula/src/formula-return.ts | 289 ++++++++++++++++++ packages/formula/src/index.ts | 5 + packages/spec/src/data/object.form.ts | 5 +- skills/objectstack-data/rules/field-types.md | 2 +- 8 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 packages/formula/src/formula-return.test.ts create mode 100644 packages/formula/src/formula-return.ts diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index df390ce5722..304128c1298 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -409,7 +409,7 @@ Calculated field using a CEL expression (see [Expressions](/docs/data-modeling/f | Property | Type | Default | Description | |:---|:---|:---|:---| | `expression` | `string \| Expression` | **required** | CEL calculation expression | -| `returnType` | `'number' \| 'text' \| 'boolean' \| 'date'` | — | Declared value type of the computed result | +| `returnType` | `'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'` | — | Declared value type of the computed result. `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares (none: dynamic) | ```typescript { name: 'total', label: 'Total', type: 'formula', expression: 'record.quantity * record.unit_price', returnType: 'number' } diff --git a/content/docs/data-modeling/formulas.mdx b/content/docs/data-modeling/formulas.mdx index d4ffbc7638c..4ecf2728193 100644 --- a/content/docs/data-modeling/formulas.mdx +++ b/content/docs/data-modeling/formulas.mdx @@ -124,10 +124,14 @@ evaluates (`objectql` computes a formula virtual field from its `expression` only). Do **not** pass `type: 'currency' | 'number' | …` to `Field.formula` — it is rejected by the typed `FieldInput` and, untyped, would override `type:'formula'` so the field silently never computes. To declare what the formula returns, set -**`returnType`** (`'number' | 'text' | 'boolean' | 'date'`). You declare it +**`returnType`** (`'number' | 'text' | 'boolean' | 'date' | 'currency'`). You declare it yourself — nothing back-fills it — but the agent-callable `validate_expression` tool reports the type it infers from the expression (`inferredType`) so an AI -author can stamp the matching value. Consumers read the declared `returnType` +author can stamp the matching value. A `'currency'` result is an amount of money +and carries its currency the way a currency field does: in the formula's own +`currencyConfig` (none means dynamic, the tenant default currency). Copy it from +the currency field the formula computes from — `inferFormulaReturn` in +`@objectstack/formula` answers both halves for a formula it can prove is money. Consumers read the declared `returnType` instead of re-parsing the expression (record-title eligibility, for one: a formula is title-eligible only when its `returnType` is `'text'`, which is what lets it be *derived* as the record title — an explicit `nameField` pointer is diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index 68cb0a7ee30..ea7c0487700 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -352,7 +352,7 @@ application-level emptiness check is needed. | Property | Type | Default | Validation Behavior | |:---|:---|:---|:---| | `expression` | `string` | — | **Required.** CEL formula expression | -| `returnType` | `enum` | — | Optional inferred result type: `number`, `text`, `boolean`, or `date` | +| `returnType` | `enum` | — | Optional inferred result type: `number`, `text`, `boolean`, `date`, or `currency` | **Default constraints:** Read-only. Value computed at runtime from `expression`. Not directly writable. diff --git a/packages/formula/src/formula-return.test.ts b/packages/formula/src/formula-return.test.ts new file mode 100644 index 00000000000..c7d7f3e7068 --- /dev/null +++ b/packages/formula/src/formula-return.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22727 — `inferFormulaReturn`, the declaration authoring stamps onto a + * formula field. The card's two pins are the first two describes: + * + * - a formula over a currency field declares the currency result; + * - CONTROL: a numeric formula stays `number`. + * + * Everything this function cannot prove to be money must answer exactly what + * `inferExpressionType` answered before it existed, so the control is asserted + * against that function directly rather than against remembered literals. + */ + +import { describe, it, expect } from 'vitest'; +import { FieldSchema } from '@objectstack/spec/data'; + +import { inferFormulaReturn, type FormulaSourceField } from './formula-return'; +import { inferExpressionType } from './validate'; + +const USD = { currencyMode: 'fixed', defaultCurrency: 'USD' } as const; +const EUR = { currencyMode: 'fixed', defaultCurrency: 'EUR' } as const; + +/** A host object's declared field map, as `ObjectSchema.fields` carries it. */ +const FIELDS: Readonly> = { + amount: { type: 'currency', currencyConfig: USD }, + tax: { type: 'currency', currencyConfig: USD }, + fee_eur: { type: 'currency', currencyConfig: EUR }, + budget: { type: 'currency' }, + spent: { type: 'currency', currencyConfig: { currencyMode: 'dynamic' } }, + probability: { type: 'percent' }, + quantity: { type: 'number' }, + discount_rate: { type: 'number' }, + flag: { type: 'boolean' }, + name: { type: 'text' }, + start_date: { type: 'date' }, + rollup: { type: 'summary' }, + weighted: { type: 'formula', returnType: 'currency', currencyConfig: USD }, + count_formula: { type: 'formula', returnType: 'number' }, + broken: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'US' } }, +}; + +const NAMES = Object.keys(FIELDS); + +/** The declaration as `inferExpressionType` alone would stamp it. */ +function plainStamp(source: string): { returnType?: string } { + const t = inferExpressionType(source, { fields: NAMES }); + return t === 'unknown' ? {} : { returnType: t }; +} + +describe('a formula over a currency field declares the currency result', () => { + it('stamps `currency` and copies a fixed source currency', () => { + expect(inferFormulaReturn('record.amount * 0.1', FIELDS)) + .toEqual({ returnType: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }); + }); + + it('a dynamic source declares no currencyConfig — absent IS dynamic, as on a currency field', () => { + expect(inferFormulaReturn('record.budget * 2', FIELDS)).toEqual({ returnType: 'currency' }); + // An explicit `dynamic` config reads the same as none. + expect(inferFormulaReturn('record.budget - record.spent', FIELDS)).toEqual({ returnType: 'currency' }); + }); + + it('the two money formulas the example apps ship are proven', () => { + // examples/app-crm `opportunity.expected_revenue` (amount: currency, probability: percent). + expect(inferFormulaReturn( + '(record.amount == null ? 0 : record.amount) * (record.probability == null ? 0 : record.probability) / 100', + FIELDS, + )).toEqual({ returnType: 'currency', currencyConfig: USD }); + // examples/app-showcase `project.budget_remaining` (budget, spent: both currency, dynamic). + expect(inferFormulaReturn( + '(record.budget == null ? 0 : record.budget) - (record.spent == null ? 0 : record.spent)', + FIELDS, + )).toEqual({ returnType: 'currency' }); + }); + + it('keeps the unit through the unit-preserving shapes', () => { + const money = { returnType: 'currency', currencyConfig: USD }; + for (const source of [ + 'record.amount + record.tax', + 'record.amount - record.tax', + 'record.amount + 10', + '-record.amount', + 'record.amount / record.quantity', + 'record.amount * (1 - record.discount_rate)', + 'record.amount * record.probability / 100', + 'round(record.amount)', + 'abs(record.amount - record.tax)', + 'max(record.amount - record.tax, 0)', + 'coalesce(record.amount, 0)', + 'record.flag ? record.amount : null', + 'record.flag ? record.amount * 2 : null', + 'record.flag ? record.amount : record.tax', + 'record.weighted * 2', + ]) { + expect(inferFormulaReturn(source, FIELDS), source).toEqual(money); + } + }); + + it('every stamped declaration is a legal formula field', () => { + for (const source of ['record.amount * 0.1', 'record.budget * 2']) { + const decl = inferFormulaReturn(source, FIELDS); + const r = FieldSchema.safeParse({ name: 'f', label: 'F', type: 'formula', expression: source, ...decl }); + expect(r.success, `${source}: ${r.success ? '' : JSON.stringify(r.error.issues)}`).toBe(true); + } + }); +}); + +describe('CONTROL — a numeric formula stays `number`', () => { + it('a formula over plain quantities stamps `number`, with no currency', () => { + expect(inferFormulaReturn('record.quantity * 2', FIELDS)).toEqual({ returnType: 'number' }); + expect(inferFormulaReturn('record.count_formula + 1', FIELDS)).toEqual({ returnType: 'number' }); + }); + + it('nothing it cannot prove is money: each answer is exactly the plain inference', () => { + for (const source of [ + 'record.quantity * 2', + // Two amounts multiplied, or a quantity divided by one, is not an amount. + 'record.amount * record.tax', + 'record.quantity / record.amount', + // A ratio of two amounts is a plain number. + 'record.amount / record.tax', + // An amount plus a quantity adds two units. + 'record.amount + record.quantity', + // Two currencies — fixed USD with fixed EUR, fixed USD with dynamic — are not one amount. + 'record.amount + record.fee_eur', + 'record.amount + record.budget', + 'record.flag ? record.amount : record.fee_eur', + // A source whose currency does not parse names no currency. + 'record.broken * 2', + // A roll-up's unit is its child field's, not readable here. + 'record.rollup * 2', + 'record.amount % 3', + 'record.amount > 100', + 'upper(record.name)', + 'today()', + 'record.amount + record.name', + 'record.unknown_field * 2', + 'size(record.name)', + ]) { + expect(inferFormulaReturn(source, FIELDS), source).toEqual(plainStamp(source)); + expect(inferFormulaReturn(source, FIELDS).returnType, source).not.toBe('currency'); + } + }); + + it('without the host\'s fields it never claims money — the plain inference, unchanged', () => { + expect(inferFormulaReturn('record.amount * 0.1')).toEqual({ returnType: 'number' }); + expect(inferFormulaReturn('record.amount * 0.1').returnType).toBe(inferExpressionType('record.amount * 0.1')); + expect(inferFormulaReturn('record.budget - record.spent')).toEqual({}); + }); + + it('an empty, missing or non-string source declares nothing', () => { + expect(inferFormulaReturn('', FIELDS)).toEqual({}); + expect(inferFormulaReturn(' ', FIELDS)).toEqual({}); + expect(inferFormulaReturn(null, FIELDS)).toEqual({}); + expect(inferFormulaReturn(undefined, FIELDS)).toEqual({}); + expect(inferFormulaReturn({ source: 1 } as never, FIELDS)).toEqual({}); + }); + + it('reads an expression envelope the way it reads a string', () => { + expect(inferFormulaReturn({ dialect: 'cel', source: 'record.amount * 0.1' }, FIELDS)) + .toEqual(inferFormulaReturn('record.amount * 0.1', FIELDS)); + }); + + it('an expression that does not type-check is never money', () => { + expect(inferFormulaReturn('record.amount *', FIELDS)).toEqual({}); + }); +}); diff --git a/packages/formula/src/formula-return.ts b/packages/formula/src/formula-return.ts new file mode 100644 index 00000000000..49abfdec9a6 --- /dev/null +++ b/packages/formula/src/formula-return.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22727 — the authoring producer of a formula field's declared result. + * + * `FieldSchema.returnType` is "stamped from the inferred CEL type". For the + * four scalar members that inference is {@link inferExpressionType}. A money + * result needs more than a CEL type: cel-js types `record.amount * 0.1` as a + * plain `double`, so the type checker can never say "this is an amount, and + * here is its currency". This module answers exactly that question, over the + * host object's declared fields, and hands the stamp the whole declaration to + * write — `returnType: 'currency'` plus the currency, copied from the source + * field's own `currencyConfig` the way a currency field carries it. + * + * ## Why authoring, and not a read-time derivation + * + * The alternative — a renderer that, finding a `currency` formula with no + * currency of its own, reads the currency of some field the expression + * references — is a `??` fallback in a consumer (AGENTS.md Prime Directive + * #12). Nothing would check it, two consumers could pick two different source + * fields, and an AI author could never see what it declared. So the currency + * is a DECLARATION on the formula (`currencyConfig`, the same schema a currency + * field uses, checked at the save door) and this function is where "taken from + * the source field" happens: once, when the formula is authored. + * + * ## What "provably money" means here — dimensional analysis, conservative + * + * Every sub-expression gets a dimension: an amount in one currency (a + * `currency` field, or a formula declared `currency`), a plain quantity (a + * numeric non-money field: `number`, `percent`, `rating`, `slider`, + * `progress`, or a formula declared `number`), a numeric constant (a literal, + * which adopts the unit it is added to — `amount + 10`, `x == null ? 0 : x`), + * the `null` literal, or unproven. The rules are the ones a unit-checker + * applies: + * + * - `+` / `-`, a ternary's two branches, `min` / `max` / `coalesce`: two + * amounts in the SAME currency stay that amount; an amount with a constant + * stays the amount; an amount with a plain quantity is unproven (`amount + + * quantity` adds two different units). + * - `*`: an amount times a quantity or constant is the amount; two amounts + * multiplied are unproven. + * - `/`: an amount divided by a quantity or constant is the amount; an amount + * divided by an amount in the same currency is a plain ratio; anything + * divided BY an amount is unproven. + * - `abs` / `round` / `floor` / `ceil` / `int` / `double` (and the `dyn` + * wrapper the parser adds to a `cond ? value : null` guard) keep the unit. + * - Everything else — a string, a comparison, a function this list does not + * name, a field whose type or currency cannot be read, two different + * currencies meeting — is unproven. + * + * Unproven is never guessed into money: the answer then is exactly what + * {@link inferExpressionType} gives today, so a numeric formula stays `number` + * and the stamp never claims a currency it cannot name. A `summary` field is + * not read as a quantity: a roll-up of a currency field is itself money, and + * its source type is not on this object. + */ + +import { CurrencyConfigSchema, NUMERIC_VALUE_TYPES, type Field } from '@objectstack/spec/data'; +import { inferCelType, parseCelToAst, rewriteNullableTernary } from './cel-engine'; +import { inferExpressionType, type ExprInput } from './validate'; + +/** + * The slice of a host-object field the inference reads — an entry of + * `ObjectSchema.fields`, as declared. Pass the object's field map unchanged. + */ +export interface FormulaSourceField { + readonly type?: string; + /** `formula` only — its declared result type; a formula over a formula reads it. */ + readonly returnType?: string; + /** A currency field's (or a currency formula's) `currencyConfig`, as declared. Absent ⇒ dynamic. */ + readonly currencyConfig?: unknown; +} + +/** + * A formula field's declared result: the pair authoring stamps onto the field. + * BOTH members are the answer — a member that is absent is one the formula does + * not declare, so a stamp writes both and clears an absent one (a formula edited + * from money to a count must lose its stale `currencyConfig`). + * + * - `returnType` — absent when no concrete type can be proven. + * - `currencyConfig` — present only for a `currency` result whose currency is + * `fixed`. A `dynamic` result (the tenant default currency) declares no config, + * exactly as a `dynamic` currency field may carry none. + */ +export interface FormulaReturnDeclaration { + readonly returnType?: NonNullable; + readonly currencyConfig?: { readonly currencyMode: 'fixed'; readonly defaultCurrency: string }; +} + +type MoneyCurrency = { readonly mode: 'dynamic' } | { readonly mode: 'fixed'; readonly code: string }; + +type Dim = + | { readonly kind: 'money'; readonly currency: MoneyCurrency } + | { readonly kind: 'quantity' } + | { readonly kind: 'constant' } + | { readonly kind: 'null' } + | { readonly kind: 'unproven' }; + +const QUANTITY: Dim = { kind: 'quantity' }; +const CONSTANT: Dim = { kind: 'constant' }; +const NULL: Dim = { kind: 'null' }; +const UNPROVEN: Dim = { kind: 'unproven' }; + +/** + * Numeric field types whose value is a plain quantity: the numeric class minus + * `currency` (an amount) and `summary` (whose unit is its child field's). + */ +const QUANTITY_FIELD_TYPES: ReadonlySet = new Set( + [...NUMERIC_VALUE_TYPES].filter((t) => t !== 'currency' && t !== 'summary'), +); + +/** One-argument calls that keep their argument's unit. `dyn` is the parser's own null-guard wrapper. */ +const UNIT_PRESERVING_CALLS: ReadonlySet = new Set(['dyn', 'double', 'int', 'abs', 'round', 'floor', 'ceil']); + +/** Two-argument calls whose result is one of their arguments. */ +const UNIT_JOINING_CALLS: ReadonlySet = new Set(['min', 'max', 'coalesce']); + +/** CEL result types a money value can carry (`dyn`: an operator over two field reads). */ +const MONEY_CEL_TYPES: ReadonlySet = new Set(['int', 'uint', 'double', 'dyn']); + +type Node = { readonly op: string; readonly args: unknown }; + +function isNode(v: unknown): v is Node { + return typeof v === 'object' && v !== null && typeof (v as Node).op === 'string'; +} + +function sameCurrency(a: MoneyCurrency, b: MoneyCurrency): boolean { + if (a.mode === 'dynamic' || b.mode === 'dynamic') return a.mode === b.mode; + return a.code === b.code; +} + +/** A field's currency, read through the schema a currency field is parsed with — or `null` when it does not parse. */ +function currencyOf(config: unknown): MoneyCurrency | null { + const parsed = CurrencyConfigSchema.safeParse(config ?? {}); + if (!parsed.success) return null; + return parsed.data.currencyMode === 'fixed' + ? { mode: 'fixed', code: parsed.data.defaultCurrency } + : { mode: 'dynamic' }; +} + +/** Is `d` a number that is not an amount (a quantity or a constant)? */ +function isPlainNumber(d: Dim): boolean { + return d.kind === 'quantity' || d.kind === 'constant'; +} + +function plainNumber(a: Dim, b: Dim): Dim { + return a.kind === 'constant' && b.kind === 'constant' ? CONSTANT : QUANTITY; +} + +/** Two values that may each be the result: ternary branches, `min` / `max` / `coalesce`. */ +function join(a: Dim, b: Dim): Dim { + if (a.kind === 'unproven' || b.kind === 'unproven') return UNPROVEN; + if (a.kind === 'null') return b; + if (b.kind === 'null') return a; + if (a.kind === 'money' && b.kind === 'money') return sameCurrency(a.currency, b.currency) ? a : UNPROVEN; + if (a.kind === 'money') return b.kind === 'constant' ? a : UNPROVEN; + if (b.kind === 'money') return a.kind === 'constant' ? b : UNPROVEN; + return plainNumber(a, b); +} + +function sum(a: Dim, b: Dim): Dim { + return a.kind === 'null' || b.kind === 'null' ? UNPROVEN : join(a, b); +} + +function product(a: Dim, b: Dim): Dim { + if (a.kind === 'money') return isPlainNumber(b) ? a : UNPROVEN; + if (b.kind === 'money') return isPlainNumber(a) ? b : UNPROVEN; + return isPlainNumber(a) && isPlainNumber(b) ? plainNumber(a, b) : UNPROVEN; +} + +function quotient(a: Dim, b: Dim): Dim { + if (a.kind === 'money' && b.kind === 'money') return sameCurrency(a.currency, b.currency) ? QUANTITY : UNPROVEN; + if (a.kind === 'money') return isPlainNumber(b) ? a : UNPROVEN; + return isPlainNumber(a) && isPlainNumber(b) ? plainNumber(a, b) : UNPROVEN; +} + +function remainder(a: Dim, b: Dim): Dim { + return isPlainNumber(a) && isPlainNumber(b) ? plainNumber(a, b) : UNPROVEN; +} + +function unitKept(d: Dim): Dim { + return d.kind === 'money' || isPlainNumber(d) ? d : UNPROVEN; +} + +function fieldDim(name: string, fields: Readonly>): Dim { + const field = Object.prototype.hasOwnProperty.call(fields, name) ? fields[name] : undefined; + if (!field) return UNPROVEN; + const type = field.type === 'formula' ? field.returnType : field.type; + if (type === 'currency') { + const currency = currencyOf(field.currencyConfig); + return currency ? { kind: 'money', currency } : UNPROVEN; + } + return typeof type === 'string' && QUANTITY_FIELD_TYPES.has(type) ? QUANTITY : UNPROVEN; +} + +function dimOf(node: unknown, fields: Readonly>): Dim { + if (!isNode(node)) return UNPROVEN; + const args = node.args; + const two = (combine: (a: Dim, b: Dim) => Dim): Dim => + Array.isArray(args) && args.length === 2 ? combine(dimOf(args[0], fields), dimOf(args[1], fields)) : UNPROVEN; + switch (node.op) { + case 'value': + if (args === null) return NULL; + return typeof args === 'number' || typeof args === 'bigint' ? CONSTANT : UNPROVEN; + case 'id': + // A bare `` resolves like `record.`, as `inferExpressionType` reads it. + return typeof args === 'string' ? fieldDim(args, fields) : UNPROVEN; + case '.': { + if (!Array.isArray(args) || args.length !== 2) return UNPROVEN; + const [base, member] = args; + return isNode(base) && base.op === 'id' && base.args === 'record' && typeof member === 'string' + ? fieldDim(member, fields) + : UNPROVEN; + } + case '-_': + return unitKept(dimOf(args, fields)); + case '+': + case '-': + return two(sum); + case '*': + return two(product); + case '/': + return two(quotient); + case '%': + return two(remainder); + case '?:': + return Array.isArray(args) && args.length === 3 ? join(dimOf(args[1], fields), dimOf(args[2], fields)) : UNPROVEN; + case 'call': { + if (!Array.isArray(args) || typeof args[0] !== 'string' || !Array.isArray(args[1])) return UNPROVEN; + const [fn, callArgs] = args as [string, unknown[]]; + if (UNIT_PRESERVING_CALLS.has(fn) && callArgs.length === 1) return unitKept(dimOf(callArgs[0], fields)); + if (UNIT_JOINING_CALLS.has(fn) && callArgs.length === 2) return join(dimOf(callArgs[0], fields), dimOf(callArgs[1], fields)); + return UNPROVEN; + } + default: + return UNPROVEN; + } +} + +function sourceOf(input: ExprInput): string | null { + const source = typeof input === 'string' + ? input + : input && typeof (input as { source?: unknown }).source === 'string' + ? (input as { source: string }).source + : null; + return source !== null && source.trim() ? source : null; +} + +/** + * The declaration authoring stamps onto a `formula` field whose expression is + * `input`, judged over the host object's declared `fields` (its + * `ObjectSchema.fields` map, passed unchanged). + * + * - Provably money ⇒ `{ returnType: 'currency' }`, plus + * `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when the + * source amounts are in one fixed currency. Sources in two different + * currencies are not provably one amount, and fall through. + * - Otherwise ⇒ the {@link inferExpressionType} answer as `returnType` + * (`number` / `text` / `boolean` / `date`), or `{}` when that is `unknown` — + * unchanged from before this function existed, so a numeric formula stays + * `number`. + * + * Pure and conservative: it never throws, and an expression that does not + * type-check is never money. + */ +export function inferFormulaReturn( + input: ExprInput, + fields: Readonly> = {}, +): FormulaReturnDeclaration { + const names = Object.keys(fields); + const source = sourceOf(input); + if (source !== null) { + // Type-checked in the form the engine evaluates: the canonical front end + // (`parseCelToAst`, and every evaluation) wraps a `cond ? value : null` + // guard's value branch in `dyn(...)`, without which cel-js refuses the + // ternary outright — so the guard idiom would never be judged at all. + const celType = inferCelType(rewriteNullableTernary(source), names); + if (celType !== null && MONEY_CEL_TYPES.has(celType)) { + const dim = dimOf(parseCelToAst(source), fields); + if (dim.kind === 'money') { + return dim.currency.mode === 'fixed' + ? { returnType: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: dim.currency.code } } + : { returnType: 'currency' }; + } + } + } + const plain = inferExpressionType(input, { fields: names }); + return plain === 'unknown' ? {} : { returnType: plain }; +} diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index 70ac210ea91..da9907653a4 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -137,6 +137,11 @@ export type { UnknownFunctionCall } from './unknown-function'; // registration, and the agent-callable validate_expression tool). export { validateExpression, introspectScope, expectedDialect, inferExpressionType, nearestName, CEL_STDLIB_FUNCTIONS } from './validate'; export type { FieldRole, ExprInput, ExprSchemaHint, ExprValidationError, ExprValidationResult, InferredValueType } from './validate'; +// #22727 — the declaration authoring stamps onto a formula field: its +// `returnType`, and for a provably-money result its currency, copied from the +// source field's own `currencyConfig`. The producer behind `returnType: 'currency'`. +export { inferFormulaReturn } from './formula-return'; +export type { FormulaReturnDeclaration, FormulaSourceField } from './formula-return'; // The closed set of refusal codes `validateExpression` and // `collectCelRootIdentifiers` carry beside their English message, with the // typed params each message interpolates — so a localized author surface keys diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 8890912cde5..3254e9a7f26 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -353,16 +353,19 @@ export const objectForm = defineForm({ // `expression`, not `formula` — the key is named for what it holds, // not for the field type that uses it. { field: 'expression', type: 'code', language: 'expression', helpText: 'CEL formula expression', visibleWhen: "data.type == 'formula'" }, - // The four members are what `FieldSchema.returnType` declares, and + // The five members are what `FieldSchema.returnType` declares, and // the same explicit list the field designer's own control carries // in `field.form.ts` (#19677). This grid additionally offered // `datetime` and `currency`, so an author who added a formula field // here and picked either wrote a value the parse refuses — the // refusal arriving from the save door, naming a key they never // typed. Declared-vs-enforced, one seam before that door. + // `currency` is declared since #22727 and offered again, in the + // same change as the enum; `datetime` is still refused. { field: 'returnType', type: 'select', helpText: 'Result type for formulas', visibleWhen: "data.type == 'formula'", options: [ { label: 'Text', value: 'text' }, { label: 'Number', value: 'number' }, { label: 'Boolean', value: 'boolean' }, { label: 'Date', value: 'date' }, + { label: 'Currency', value: 'currency' }, ] }, // A roll-up is ONE key — `summaryOperations` {object, field, function}. // The flat `summaryType` / `summaryField` pair named neither of them diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 762a116e6a7..93181c891e4 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -250,7 +250,7 @@ import { F } from '@objectstack/spec'; const total = { type: 'formula', expression: F`record.amount * record.tax_rate`, // CEL — `record.` prefixes required - returnType: 'number', // 'number' | 'text' | 'boolean' | 'date' (no 'currency') + returnType: 'number', // 'number' | 'text' | 'boolean' | 'date' | 'currency' (+ currencyConfig) }; ``` From 882b2dbb9d3698cca6e0d188368c897f1d99d221 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:39:29 +0000 Subject: [PATCH 4/8] chore(spec): the inline form field's returnType names currency; analytics walk holds a currency formula refused Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- content/docs/references/data/field.mdx | 4 ++-- content/docs/references/data/object.mdx | 8 ++++---- content/docs/references/system/migration.mdx | 8 ++++---- content/docs/references/ui/component.mdx | 2 +- .../src/__tests__/formula-return-type-measure.test.ts | 10 ++++++---- packages/spec/src/ui/component.zod.ts | 2 +- 6 files changed, 18 insertions(+), 16 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 2122168cefb..dfbf5b78aa4 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -95,11 +95,11 @@ const result = CurrencyConfigSchema.parse(data); | **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. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` | -| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date'>` | optional | Inferred value type of a formula field (number/text/boolean/date) | +| **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. | | **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'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | 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/data/object.mdx b/content/docs/references/data/object.mdx index c1455e9c07e..2e810bf01cd 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -260,11 +260,11 @@ const result = ApiMethod.parse(data); | **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. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` | -| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date'>` | optional | Inferred value type of a formula field (number/text/boolean/date) | +| **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. | | **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'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | 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") | @@ -601,11 +601,11 @@ const result = ApiMethod.parse(data); | **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. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` | -| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date'>` | optional | Inferred value type of a formula field (number/text/boolean/date) | +| **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. | | **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'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | 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 065abea7a5d..86603a356df 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -96,11 +96,11 @@ Add a new field to an existing object | **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. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` | -| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date'>` | optional | Inferred value type of a formula field (number/text/boolean/date) | +| **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. | | **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'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | 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") | @@ -522,11 +522,11 @@ Add a new field to an existing object | **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. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` | -| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date'>` | optional | Inferred value type of a formula field (number/text/boolean/date) | +| **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. | | **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'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | 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/ui/component.mdx b/content/docs/references/ui/component.mdx index 4fd631069bc..142ba93cf82 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -606,7 +606,7 @@ Sort field and direction pair | **minLength** | `integer` | optional | Minimum character count — the native control's `minlength` | | **maxLength** | `integer` | optional | Maximum character count — the control's ceiling (a textarea also draws its counter) | | **pattern** | `string` | optional | Regular expression the value must match, as a string — the native control's `pattern`, enforced by the browser at submit | -| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date'>` | optional | The value type a formula field displays (number / text / boolean / date) | +| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | The value type a formula field displays (number / text / boolean / date) | | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | The roll-up a summary field displays — the object field's own `{ object, field, function, … }` | | **columns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | The columns of a `grid` field — the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes | | **minRows** | `number` | optional | A `grid` field's minimum row count: its Remove action is disabled at this many rows (no minimum when unset). Read only by the `grid` widget | diff --git a/packages/services/service-analytics/src/__tests__/formula-return-type-measure.test.ts b/packages/services/service-analytics/src/__tests__/formula-return-type-measure.test.ts index a421a4b29e0..86f2a4514b9 100644 --- a/packages/services/service-analytics/src/__tests__/formula-return-type-measure.test.ts +++ b/packages/services/service-analytics/src/__tests__/formula-return-type-measure.test.ts @@ -77,7 +77,7 @@ import { AnalyticsService } from '../analytics-service.js'; import { MEASURE_RESULT_TYPE_TEMPORAL, measureResultType } from '../measure-result-type.js'; /** - * The four members, read off the SPEC schema rather than restated here — the + * The declared members, read off the SPEC schema rather than restated here — the * same construction the `FieldType` walk in `measure-result-type.test.ts` uses. * A fifth member added upstream reds the exhaustiveness case below instead of * quietly inheriting a verdict nobody wrote for it. @@ -93,10 +93,12 @@ const SPEC_RETURN_TYPES: readonly string[] = (() => { })(); describe('A) the formula branch is retired — no declared returnType buys a result type back', () => { - it('the spec still declares the four members this file was written about', () => { + it('the spec still declares the four members this file was written about, and #22727\'s `currency`', () => { // The enum is read, not restated: the reversal is about what the MEASURE - // rule does with `returnType`, not about the key going away. - expect([...SPEC_RETURN_TYPES].sort()).toEqual(['boolean', 'date', 'number', 'text']); + // rule does with `returnType`, not about the key going away. #22727 added + // `currency` — a formula is still VIRTUAL in SQL storage whatever it + // returns, so the walk below holds it to the same `undefined` as the four. + expect([...SPEC_RETURN_TYPES].sort()).toEqual(['boolean', 'currency', 'date', 'number', 'text']); }); it('⭐ the table REFUSES `min` / `max` over `formula` — the ground the ruling stood on', () => { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 32bc5f71ecd..911294d48c1 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -7198,7 +7198,7 @@ function buildObjectFormRuntimeField() { minLength: FieldSchema.shape.minLength.describe('Minimum character count — the native control\'s `minlength`'), maxLength: FieldSchema.shape.maxLength.describe('Maximum character count — the control\'s ceiling (a textarea also draws its counter)'), pattern: z.string().optional().describe('Regular expression the value must match, as a string — the native control\'s `pattern`, enforced by the browser at submit'), - returnType: FieldSchema.shape.returnType.describe('The value type a formula field displays (number / text / boolean / date)'), + returnType: FieldSchema.shape.returnType.describe('The value type a formula field displays (number / text / boolean / date / currency)'), summaryOperations: FieldSchema.shape.summaryOperations.describe('The roll-up a summary field displays — the object field\'s own `{ object, field, function, … }`'), columns: FieldSchema.shape.inlineColumns.describe('The columns of a `grid` field — the strict, name-keyed inline grid column a relationship field\'s `inlineColumns` takes'), // [#21768] The `grid` widget's eight field-level keys, camelCase since From 4f1219522ab36552c5fc1343a0eacf8069abd659 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:41:38 +0000 Subject: [PATCH 5/8] chore(spec): regenerate the component reference for the currency returnType Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 142ba93cf82..87c0f0778c4 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -606,7 +606,7 @@ Sort field and direction pair | **minLength** | `integer` | optional | Minimum character count — the native control's `minlength` | | **maxLength** | `integer` | optional | Maximum character count — the control's ceiling (a textarea also draws its counter) | | **pattern** | `string` | optional | Regular expression the value must match, as a string — the native control's `pattern`, enforced by the browser at submit | -| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | The value type a formula field displays (number / text / boolean / date) | +| **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | The value type a formula field displays (number / text / boolean / date / currency) | | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | The roll-up a summary field displays — the object field's own `{ object, field, function, … }` | | **columns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | The columns of a `grid` field — the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes | | **minRows** | `number` | optional | A `grid` field's minimum row count: its Remove action is disabled at this many rows (no minimum when unset). Read only by the `grid` widget | From 20d64a3102186e93485ccdf8a735f171cbff491f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:48:53 +0000 Subject: [PATCH 6/8] chore(platform-objects): designer help text names the currency result in all four locales; changeset Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- .changeset/22727-formula-currency-result.md | 16 ++++++++++++++++ .../translations/en.metadata-forms.generated.ts | 4 ++-- .../es-ES.metadata-forms.generated.ts | 4 ++-- .../ja-JP.metadata-forms.generated.ts | 4 ++-- .../zh-CN.metadata-forms.generated.ts | 4 ++-- 5 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 .changeset/22727-formula-currency-result.md diff --git a/.changeset/22727-formula-currency-result.md b/.changeset/22727-formula-currency-result.md new file mode 100644 index 00000000000..991c176a993 --- /dev/null +++ b/.changeset/22727-formula-currency-result.md @@ -0,0 +1,16 @@ +--- +"@objectstack/spec": minor +"@objectstack/formula": minor +"@objectstack/platform-objects": patch +--- + +A formula field can declare a currency result: `returnType: 'currency'`, with its currency in its own `currencyConfig` + +Clause-②: yes (widening) + +- **What is new in `@objectstack/spec`.** `FieldSchema.returnType` accepts `'currency'` beside `'number'`, `'text'`, `'boolean'` and `'date'`. A `currency` result is an amount of money. Its value is a bare number, as a currency field's is. Its currency is the formula field's own `currencyConfig`, the same `CurrencyConfigSchema` a currency field carries, with the same defaults and the same refusals: no `currencyConfig` means dynamic mode (the tenant default currency), and `{ currencyMode: 'fixed', defaultCurrency: 'USD' }` fixes it. Nothing is read at display time from the fields the expression references: the currency is a declaration on the formula. +- **Readers.** The filter doors judge a formula returning `currency` as the currency field it reads like: a text operator on it is refused (`INVALID_FILTER`), and the number door judges its comparands. A formula is still not title-eligible unless it returns `text`, and is still refused for `sum` / `avg` / `min` / `max` (it has no stored column). +- **Designer forms.** The field designer and the object designer's field grid both offer Currency as a return type. The field designer shows its Currency Config row for a formula whose return type is Currency, as it does for a currency field. +- **What is new in `@objectstack/formula`.** `inferFormulaReturn(expression, fields)` returns the declaration authoring stamps onto a formula field, judged over the host object's declared field map: `{ returnType: 'currency' }` when the expression is provably an amount of money, plus `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when its source amounts are in one fixed currency. "Provably money" is unit checking over the expression: amounts in one currency added, subtracted, scaled by a number or a percent, divided by a number, rounded, or picked by `min` / `max` / `coalesce` / a ternary. Two different currencies, an amount multiplied by an amount, or an amount plus a plain quantity are not money. For everything it cannot prove to be money it answers exactly what `inferExpressionType` answers, so a numeric formula stays `number`. Its types `FormulaReturnDeclaration` and `FormulaSourceField` are exported with it. `inferExpressionType` is unchanged. +- **`@objectstack/platform-objects`.** The designer help text for the return type and the currency config rows names the currency result, in all four locales. +- **Nothing to migrate.** Every value accepted before is accepted now. To declare a formula over money as money, declare `returnType: 'currency'` and copy the source currency field's `currencyConfig` onto the formula, or run `inferFormulaReturn` and write what it returns. diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 1032ce29f19..1c50a68b100 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -594,7 +594,7 @@ export const enMetadataForms: NonNullable = { }, currencyConfig: { label: "Currency Config", - helpText: "Which currency this field is in. Unset: dynamic mode. The stored value is a bare number in either mode." + helpText: "Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in either mode." }, "currencyConfig.currencyMode": { label: "Currency Mode", @@ -744,7 +744,7 @@ export const enMetadataForms: NonNullable = { }, returnType: { label: "Return Type", - helpText: "Declared value type of the formula, stamped from the inferred CEL type. Consumers read it instead of re-parsing the expression." + helpText: "Declared value type of the formula, stamped from the inferred CEL type. Consumers read it instead of re-parsing the expression. Currency: an amount of money, in the currency the Currency Config of this field declares (dynamic when unset)." }, summaryOperations: { label: "Summary Operations", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index a1e8f93ad4b..9020adffdd9 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -594,7 +594,7 @@ export const esESMetadataForms: NonNullable = }, currencyConfig: { label: "Configuración de moneda", - helpText: "En qué moneda está este campo. Sin definir: modo dynamic. En ambos modos el valor almacenado es un número sin más." + helpText: "En qué moneda está este campo: un campo de moneda, o una fórmula cuyo tipo de retorno es Moneda. Sin definir: modo dynamic. En ambos modos el valor es un número sin más." }, "currencyConfig.currencyMode": { label: "Modo de moneda", @@ -744,7 +744,7 @@ export const esESMetadataForms: NonNullable = }, returnType: { label: "Tipo de retorno", - helpText: "Tipo de valor declarado de la fórmula, registrado a partir del tipo CEL inferido. Los consumidores lo leen en vez de volver a analizar la expresión." + helpText: "Tipo de valor declarado de la fórmula, registrado a partir del tipo CEL inferido. Los consumidores lo leen en vez de volver a analizar la expresión. Moneda: un importe, en la moneda que declara la configuración de moneda de este campo (dynamic si no se define)." }, summaryOperations: { label: "Operaciones de resumen", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index f073df2d6e4..1c98f5e908f 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -594,7 +594,7 @@ export const jaJPMetadataForms: NonNullable = }, currencyConfig: { label: "通貨設定", - helpText: "このフィールドの通貨。未設定の場合は dynamic モードです。どちらのモードでも、保存される値は単なる数値です。" + helpText: "このフィールドの通貨(通貨フィールド、または戻り値型が「通貨」の数式フィールド)。未設定の場合は dynamic モードです。どちらのモードでも、値は単なる数値です。" }, "currencyConfig.currencyMode": { label: "通貨モード", @@ -744,7 +744,7 @@ export const jaJPMetadataForms: NonNullable = }, returnType: { label: "数式の戻り値型", - helpText: "数式が宣言する値の型。推論された CEL の型から記録され、利用側は式を解析し直さずにこの値を読みます。" + helpText: "数式が宣言する値の型。推論された CEL の型から記録され、利用側は式を解析し直さずにこの値を読みます。通貨:金額で、その通貨はこのフィールドの通貨設定が宣言します(未設定の場合は dynamic)。" }, summaryOperations: { label: "集計操作", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 9a787cb22c9..7eef677d93a 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -594,7 +594,7 @@ export const zhCNMetadataForms: NonNullable = }, currencyConfig: { label: "货币配置", - helpText: "此字段使用哪种货币。未设置时为 dynamic 模式。两种模式下存储的值都是一个纯数字。" + helpText: "此字段使用哪种货币——货币字段,或返回类型为“货币”的公式字段。未设置时为 dynamic 模式。两种模式下值都是一个纯数字。" }, "currencyConfig.currencyMode": { label: "货币模式", @@ -744,7 +744,7 @@ export const zhCNMetadataForms: NonNullable = }, returnType: { label: "公式返回类型", - helpText: "公式声明的值类型,按推断出的 CEL 类型写入。消费方直接读它,无需重新解析表达式。" + helpText: "公式声明的值类型,按推断出的 CEL 类型写入。消费方直接读它,无需重新解析表达式。货币:一笔金额,其货币由此字段的货币配置声明(未设置时为 dynamic)。" }, summaryOperations: { label: "汇总操作", From 40693a3456322fe182c67a2022612a4b190d82da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:56:11 +0000 Subject: [PATCH 7/8] revert(skills): leave the field-types rule line to its own skills-lane card The '(no currency)' comment goes false when the currency result lands; it moves to a skills card blocked on this one, so this PR touches no governed surface. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- skills/objectstack-data/rules/field-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 93181c891e4..762a116e6a7 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -250,7 +250,7 @@ import { F } from '@objectstack/spec'; const total = { type: 'formula', expression: F`record.amount * record.tax_rate`, // CEL — `record.` prefixes required - returnType: 'number', // 'number' | 'text' | 'boolean' | 'date' | 'currency' (+ currencyConfig) + returnType: 'number', // 'number' | 'text' | 'boolean' | 'date' (no 'currency') }; ``` From f401351a3b928db4f1c7059bf85e8d41296a813a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:16:50 +0000 Subject: [PATCH 8/8] test(lint): the shipped-predicate literal census counts the currency formula's Currency Config gate (62 -> 64) Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude --- packages/lint/src/validate-predicate-path-refs.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 5945b81148e..0d928ed61c5 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -771,7 +771,10 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // It is 62 today: #21863's two new predicates each hold two single-quoted // literal comparisons, `data.type == 'api'` and `data.type == 'script'`, // on `action :: outcomeMessages` and on `action :: onSuccess`. - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(62); + // It is 64 today: #22727's `field :: currencyConfig` gate also opens for a + // currency formula, `data.type == 'formula' && data.returnType == 'currency'`, + // two more single-quoted literal comparisons. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(64); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED);