diff --git a/.changeset/24619-stored-expression-computed-on-write.md b/.changeset/24619-stored-expression-computed-on-write.md new file mode 100644 index 00000000000..7832a82aa44 --- /dev/null +++ b/.changeset/24619-stored-expression-computed-on-write.md @@ -0,0 +1,22 @@ +--- +'@objectstack/objectql': minor +'@objectstack/spec': minor +--- + +feat(objectql)!: a stored field's declared `expression` is computed by the engine on every write; a client-sent value no longer stands + +Clause-②: no (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the launch-window convention for breaking changes. + +A field that is not a `formula` and declares `expression` is a stored computed column: it has a column, so a `summary` can roll it up. The engine evaluated `expression` for `formula` fields only, so the value a client sent for such a field was stored as-is. On the showcase, a paid invoice's lines lock `quantity` and `unit_price` with `readonlyWhen: parent.status == 'paid'`, and the line `amount` declares `expression: record.quantity * record.unit_price`. A `/batch` update carrying `quantity` and `amount` had `quantity` dropped and `amount` stored, and a `PATCH` of `amount` alone was accepted, so either write moved the paid invoice's `total`. + +- **FROM.** `insert` and `update` stored the value the caller sent for a stored field that declares `expression`, or left it as it was when the caller sent none. +- **TO.** The engine computes the field from the row the write stores: the defaulted row on an insert, and the stored row merged with the patch on an update. The stored value is computed after the `before*` hooks and both read-only strips, so an input the `readonlyWhen` strip took back out feeds the stored value, never the caller's. A caller's value is overwritten, in every context. An update by id that moves none of the expression's inputs stores the value the row already computes to. +- **What the `before*` hooks are handed.** On an insert, the expression over the defaulted row. On an update by id, the expression over the stored row merged with the caller's patch before the strips run, so it is not the stored value when the patch carries a locked input; a caller's value for a field that is also `readonly` stays withheld from the hooks and is judged by the read-only strip as before (reported as a `readonly` drop, and refused under `strictReadonlyWrites`). On a predicate update, the per-row hooks are handed the caller's patch; the value is settled after them. An expression that does not evaluate (an input is empty) stores `null`, with the fault logged once, as a `formula` reads `null`. A `summary` over the column reads the computed values. +- **Refused now.** A predicate update (`multi: true`) whose patch would compute different values on different matched rows is refused with `VALIDATION_FAILED` (field error `invalid_value`, naming the field) before anything is written: one update of all the matched rows cannot store a value per row. A predicate update that computes the same value on every row stores it; one that moves no input leaves the column alone and drops the caller's value. +- **Unchanged.** `formula` fields; a field that declares no `expression`; a client that sends the computed value, as the console's line-item grid does (it recomputes the column client-side); the `readonly` drop report and the `strictReadonlyWrites` refusal of a caller's value for a stored expression field that is also `readonly`. + +**Fix.** Nothing, if your client sends the computed value or none. To store a value the expression does not compute, remove the field's `expression`. A predicate update that is refused: update those rows by id, so each is computed from its own row. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 18fed0b59b5..2ccdfe9827e 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -96,7 +96,7 @@ const result = CurrencyConfigSchema.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | -| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` | +| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index bf1245a1591..67edc399410 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -262,7 +262,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | -| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` | +| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | @@ -618,7 +618,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | -| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` | +| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 9b491b4cca8..63ac5b26c39 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -97,7 +97,7 @@ Add a new field to an existing object | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | -| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` | +| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | @@ -526,7 +526,7 @@ Add a new field to an existing object | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | -| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` | +| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | diff --git a/examples/app-crm/src/objects/opportunity-line-item.object.ts b/examples/app-crm/src/objects/opportunity-line-item.object.ts index 96cd559b991..b807ec26e22 100644 --- a/examples/app-crm/src/objects/opportunity-line-item.object.ts +++ b/examples/app-crm/src/objects/opportunity-line-item.object.ts @@ -51,11 +51,12 @@ export const OpportunityLineItem = ObjectSchema.create({ }), // Amount = Qty × Unit Price. Kept as a *stored* currency column (so the // parent Opportunity.line_total summary can roll it up — summary aggregation - // reads stored columns, not on-read formula fields), but the `expression` - // makes the line-item grid render it READ-ONLY and recompute it live - // client-side as quantity/unit_price change, then persist the computed - // value. The server stores the client-sent value as-is. (Mirrors the - // showcase InvoiceLine.amount pattern.) + // reads stored columns, not on-read formula fields). The server computes a + // stored field's `expression` on every insert and update, from the stored + // line merged with the change, so a client-sent amount never stands and + // line_total always agrees with the lines. The line-item grid renders it + // READ-ONLY and recomputes it live client-side as quantity/unit_price + // change. (Mirrors the showcase InvoiceLine.amount pattern.) amount: Field.currency({ label: 'Amount', min: 0, diff --git a/examples/app-showcase/src/data/objects/invoice.object.ts b/examples/app-showcase/src/data/objects/invoice.object.ts index c89347b7cff..969751ba4f4 100644 --- a/examples/app-showcase/src/data/objects/invoice.object.ts +++ b/examples/app-showcase/src/data/objects/invoice.object.ts @@ -322,11 +322,13 @@ export const InvoiceLine = ObjectSchema.create({ receipt: Field.file({ label: 'Receipt' }), // Amount = Qty × Unit Price. Kept as a *stored* currency column (so the // parent Invoice.total summary can roll it up — summary aggregation reads - // stored columns, not on-read formula fields), but the `expression` makes - // the line-item grid render it READ-ONLY and recompute it live client-side - // as quantity/unit_price change, then persist the computed value. The - // server does not treat a non-`formula` field's expression as computed, so - // the client-sent value is stored as-is. + // stored columns, not on-read formula fields). The server computes a + // stored field's `expression` on every insert and update, from the stored + // line merged with the change, so a client-sent amount never stands: on a + // paid invoice the locked quantity/unit_price keep the amount — and so the + // total — frozen with them, with no `readonlyWhen` of its own. The line-item + // grid renders it READ-ONLY and recomputes it live client-side as + // quantity/unit_price change. amount: Field.currency({ label: 'Amount', min: 0, diff --git a/packages/objectql/src/engine-stored-expression-write.test.ts b/packages/objectql/src/engine-stored-expression-write.test.ts new file mode 100644 index 00000000000..cea0c621529 --- /dev/null +++ b/packages/objectql/src/engine-stored-expression-write.test.ts @@ -0,0 +1,273 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #24619 (item a) — a STORED field's declared `expression` is computed by the +// engine on every write. +// +// `showcase_invoice_line.amount` is a stored `currency` column declaring +// `expression: record.quantity * record.unit_price`, kept stored so the header's +// `total` summary can roll it up. The engine evaluated `expression` for +// `type: 'formula'` fields only, so the client-sent `amount` was stored as-is: +// on the PAID invoice INV-1003, whose line inputs are frozen by +// `readonlyWhen: parent.status == 'paid'`, a `/batch` update carrying +// `{quantity, amount}` had `quantity` dropped and `amount` stored, and a plain +// PATCH of `amount` alone was accepted — either one moved the paid invoice's +// `total`. Now the stored value is the expression over the row the write +// stores (the stored row merged with the patch, after the hooks and both +// read-only strips), and a client-sent value never stands. +// +// The suite drives the real engine over a real-shaped driver, the showcase's +// shape trimmed to what the computation needs. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; +import { ReadonlyFieldRejectedError } from './readonly-strict-errors.js'; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const checkOp = (value: any, cond: any): boolean => { + if (cond === null || typeof cond !== 'object' || Array.isArray(cond) || cond instanceof Date) { + return value === cond; + } + return Object.entries(cond).every(([op, target]: [string, any]) => { + switch (op) { + case '$eq': return value === target; + case '$ne': return value !== target; + case '$in': return Array.isArray(target) && target.includes(value); + default: return true; + } + }); + }; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k === '$or') return (v as any[]).some((w) => matches(row, w)); + if (k === '$not') return !matches(row, v); + return checkOp(row?.[k], v); + }); + }; + let n = 0; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + n += 1; + const id = (data.id as string) ?? `r_${n}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const row = { ...s.get(id), ...data, id }; + s.set(id, row); + return row; + }, + async updateMany(object: string, ast: any, data: Record) { + const s = storeFor(object); + let count = 0; + for (const row of [...s.values()]) { + if (!matches(row, ast?.where)) continue; + s.set(row.id, { ...row, ...data, id: row.id }); + count += 1; + } + return count; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count() { return 0; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r, undefined))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +describe('a stored field\'s declared expression is computed on write (#24619)', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + + beforeEach(async () => { + engine = new ObjectQL(); + const d = makeDriver(); + storeFor = d.storeFor; + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject({ + name: 'showcase_invoice', + fields: { + status: { type: 'select', options: [{ value: 'draft' }, { value: 'paid' }] }, + }, + } as any); + engine.registry.registerObject({ + name: 'showcase_invoice_line', + fields: { + invoice: { type: 'master_detail', reference: 'showcase_invoice', required: true }, + quantity: { type: 'number', readonlyWhen: "parent.status == 'paid'" }, + unit_price: { type: 'currency', readonlyWhen: "parent.status == 'paid'" }, + description: { type: 'text' }, + // invoice.object.ts: stored, no `readonlyWhen` of its own. + amount: { type: 'currency', min: 0, expression: { dialect: 'cel', source: 'record.quantity * record.unit_price' } }, + }, + } as any); + storeFor('showcase_invoice').set('INV-1003', { id: 'INV-1003', status: 'paid' }); + storeFor('showcase_invoice').set('INV-1004', { id: 'INV-1004', status: 'draft' }); + storeFor('showcase_invoice_line').set('line_paid', { id: 'line_paid', invoice: 'INV-1003', quantity: 6, unit_price: 49.99, amount: 299.94, description: 'seat' }); + storeFor('showcase_invoice_line').set('line_draft', { id: 'line_draft', invoice: 'INV-1004', quantity: 3, unit_price: 10, amount: 30, description: 'seat' }); + storeFor('showcase_invoice_line').set('line_draft_2', { id: 'line_draft_2', invoice: 'INV-1004', quantity: 2, unit_price: 7, amount: 14, description: 'other' }); + }); + + const line = (id: string) => storeFor('showcase_invoice_line').get(id); + /** The header `total` summary's input: the sum of its lines' STORED `amount`. */ + const total = (invoice: string) => [...storeFor('showcase_invoice_line').values()] + .filter((r) => r.invoice === invoice) + .reduce((sum, r) => sum + (r.amount ?? 0), 0); + + describe('on the paid invoice, neither of the card\'s writes moves the total', () => { + it('the /batch update carrying quantity and amount: quantity is dropped, amount is the stored inputs\' product', async () => { + const events: any[] = []; + await engine.update( + 'showcase_invoice_line', + { id: 'line_paid', quantity: 60, amount: 2999.4 }, + { onFieldsDropped: (e: any) => events.push(e) } as any, + ); + expect(line('line_paid')).toMatchObject({ quantity: 6, unit_price: 49.99, amount: 299.94 }); + expect(total('INV-1003')).toBe(299.94); + expect(events).toEqual([{ object: 'showcase_invoice_line', fields: ['quantity'], reason: 'readonly_when' }]); + }); + + it('the plain PATCH of amount alone stores the computed amount', async () => { + await engine.update('showcase_invoice_line', { id: 'line_paid', amount: 1 }); + expect(line('line_paid').amount).toBe(299.94); + expect(total('INV-1003')).toBe(299.94); + }); + }); + + describe('a draft invoice\'s line saves as before, with amount = quantity * unit_price', () => { + it('the grid\'s save (all three, consistent) is stored as sent', async () => { + await engine.update('showcase_invoice_line', { id: 'line_draft', quantity: 5, unit_price: 20, amount: 100 }); + expect(line('line_draft')).toMatchObject({ quantity: 5, unit_price: 20, amount: 100 }); + expect(total('INV-1004')).toBe(114); + }); + + it('update: no amount sent — computed from the stored row merged with the patch', async () => { + await engine.update('showcase_invoice_line', { id: 'line_draft', quantity: 7 }); + expect(line('line_draft')).toMatchObject({ quantity: 7, unit_price: 10, amount: 70 }); + }); + + it('update: a wrong amount sent — overwritten by the computed one', async () => { + await engine.update('showcase_invoice_line', { id: 'line_draft', quantity: 4, amount: 5 }); + expect(line('line_draft')).toMatchObject({ quantity: 4, amount: 40 }); + }); + + it('update: a patch that touches no input stores the amount the row already computes to', async () => { + await engine.update('showcase_invoice_line', { id: 'line_draft', description: 'renamed' }); + expect(line('line_draft')).toMatchObject({ description: 'renamed', amount: 30 }); + }); + + it('insert: no amount, a wrong amount and the right amount each store quantity * unit_price', async () => { + const none = await engine.insert('showcase_invoice_line', { invoice: 'INV-1004', quantity: 2, unit_price: 10 }); + const wrong = await engine.insert('showcase_invoice_line', { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 999 }); + const right = await engine.insert('showcase_invoice_line', { invoice: 'INV-1004', quantity: 2, unit_price: 10, amount: 20 }); + for (const r of [none, wrong, right]) expect(line(r.id).amount).toBe(20); + }); + + it('a batch insert computes each row from its own inputs', async () => { + const rows = await engine.insert('showcase_invoice_line', [ + { invoice: 'INV-1004', quantity: 1, unit_price: 3, amount: 0 }, + { invoice: 'INV-1004', quantity: 4, unit_price: 3 }, + ]); + expect(rows.map((r: any) => line(r.id).amount)).toEqual([3, 12]); + }); + }); + + describe('the hooks see, and may change, what is stored', () => { + it('a beforeUpdate hook is handed the computed amount, and a hook-changed input is recomputed', async () => { + const seen: unknown[] = []; + engine.registerHook('beforeUpdate', async (ctx: any) => { + seen.push(ctx.input.data.amount); + ctx.input.data.quantity = 9; + }, { object: 'showcase_invoice_line' } as any); + await engine.update('showcase_invoice_line', { id: 'line_draft', quantity: 5, amount: 1 }); + expect(seen).toEqual([50]); + expect(line('line_draft')).toMatchObject({ quantity: 9, amount: 90 }); + }); + }); + + describe('a stored expression field that is also `readonly`, updated by id with its value and a moved input', () => { + // The pre-hook pass runs inside the hook-write recording window. Were it + // to write through the recording, the engine's value would read as a + // HOOK write and the static strip would keep it: no `readonly` report, + // no strict refusal. The strips judge the caller's submission instead. + beforeEach(() => { + engine.registry.registerObject({ + name: 'qa_locked_line', + fields: { + quantity: { type: 'number' }, + unit_price: { type: 'currency' }, + amount: { type: 'currency', readonly: true, expression: { dialect: 'cel', source: 'record.quantity * record.unit_price' } }, + }, + } as any); + storeFor('qa_locked_line').set('ll_1', { id: 'll_1', quantity: 3, unit_price: 10, amount: 30 }); + }); + + it('reports the caller\'s value as a `readonly` drop and stores the expression\'s', async () => { + const events: any[] = []; + await engine.update( + 'qa_locked_line', + { id: 'll_1', quantity: 4, amount: 5 }, + { onFieldsDropped: (e: any) => events.push(e) } as any, + ); + expect(events).toContainEqual({ object: 'qa_locked_line', fields: ['amount'], reason: 'readonly' }); + expect(storeFor('qa_locked_line').get('ll_1')).toMatchObject({ quantity: 4, amount: 40 }); + }); + + it('is refused under `strictReadonlyWrites`, writing nothing', async () => { + const err: any = await engine + .update('qa_locked_line', { id: 'll_1', quantity: 4, amount: 5 }, { strictReadonlyWrites: true } as any) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(ReadonlyFieldRejectedError); + expect(err.fields).toContain('amount'); + expect(storeFor('qa_locked_line').get('ll_1')).toMatchObject({ quantity: 3, amount: 30 }); + }); + }); + + describe('a predicate update', () => { + it('stores the one computed value when it is the same on every matched row', async () => { + await engine.update('showcase_invoice_line', { quantity: 2, unit_price: 5, amount: 1 }, { where: { invoice: 'INV-1004' }, multi: true } as any); + expect(line('line_draft').amount).toBe(10); + expect(line('line_draft_2').amount).toBe(10); + }); + + it('leaves the column alone when the patch moves no input, and never stores the caller\'s value', async () => { + await engine.update('showcase_invoice_line', { description: 'bulk', amount: 1 }, { where: { invoice: 'INV-1004' }, multi: true } as any); + expect(line('line_draft')).toMatchObject({ description: 'bulk', amount: 30 }); + expect(line('line_draft_2')).toMatchObject({ description: 'bulk', amount: 14 }); + }); + + it('is refused, writing nothing, when it would compute different values on different rows', async () => { + const err: any = await engine + .update('showcase_invoice_line', { quantity: 10 }, { where: { invoice: 'INV-1004' }, multi: true } as any) + .catch((e: unknown) => e); + expect(err?.code).toBe('VALIDATION_FAILED'); + expect(err?.fields).toEqual([expect.objectContaining({ field: 'amount', code: 'invalid_value' })]); + expect(line('line_draft')).toMatchObject({ quantity: 3, amount: 30 }); + expect(line('line_draft_2')).toMatchObject({ quantity: 2, amount: 14 }); + }); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 1ab84f10b83..e3b5f0a1bcf 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -295,7 +295,7 @@ import { deriveViewContainerObject } from '@objectstack/metadata/view-container' import { viewContainerNameRefusal } from './view-container-name-refusal.js'; import { bindHooksToEngine, type BindHooksOptions } from './hook-binder.js'; import { declaredHookCondition } from './hook-wrappers.js'; -import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; +import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, type FieldValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js'; import { collectPredicateRelationships, collectReferenceTraversals, referenceTraversalConflicts, evaluateValidationRules, emitValidationAdvisories, optionVisibilityReadsPermissions, readsPermissionPredicate, referentialClearBinding, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; @@ -1677,6 +1677,36 @@ function statesDeleteSemantics(fdef: { type?: unknown } | null | undefined): boo * once the read is done, so the caller gets the projection it declared plus * the formula's value. `widened` is absent whenever nothing was widened. */ +/** + * ONE plan entry for a field's declared `expression`: the envelope, the + * rounding scale, and the one hop it reads through a reference field. Shared + * by the `formula` read plan ({@link planFormulaProjection}) and the stored + * computed-field write plan ({@link planStoredExpressions}), so a value + * computed on read and a value computed on write are one semantic. + */ +function formulaPlanEntry(name: string, def: any, fields: any): FormulaPlanEntry { + // Normalize string-shorthand → Expression envelope (M9 transition). + const expr: Expression = typeof def.expression === 'string' + ? { dialect: 'cel', source: def.expression } + : def.expression; + const entry: FormulaPlanEntry = { name, expression: expr, scale: formulaRoundingScale(def) }; + // [#24197] The one hop this formula reads through a reference field, if + // any — judged by the validation-rule path's own collector, so the two + // seams that serve a hop read the same hops off the same expression. + const conflicts = referenceTraversalConflicts(expr, fields, 'formula'); + if (conflicts.length > 0) { + entry.hopConflict = conflicts.map((c) => c.message).join(' '); + } else { + const hops = collectReferenceTraversals([expr], fields); + if (hops.size > 0) entry.hops = hops; + } + // Pre-compile to surface syntax errors at planning stage rather than + // per-row eval. Own-column dependency discovery is not used — CEL reads + // the row via `record.`; only the hops above are planned. + ExpressionEngine.compile(expr); + return entry; +} + function planFormulaProjection( schema: any, requestedFields: string[] | undefined @@ -1694,26 +1724,7 @@ function planFormulaProjection( for (const f of targets) { const def = (schema.fields as any)[f]; if (def?.type === 'formula' && def.expression) { - // Normalize string-shorthand → Expression envelope (M9 transition). - const expr: Expression = typeof def.expression === 'string' - ? { dialect: 'cel', source: def.expression } - : def.expression; - const entry: FormulaPlanEntry = { name: f, expression: expr, scale: formulaRoundingScale(def) }; - // [#24197] The one hop this formula reads through a reference field, if - // any — judged by the validation-rule path's own collector, so the two - // seams that serve a hop read the same hops off the same expression. - const conflicts = referenceTraversalConflicts(expr, schema.fields, 'formula'); - if (conflicts.length > 0) { - entry.hopConflict = conflicts.map((c) => c.message).join(' '); - } else { - const hops = collectReferenceTraversals([expr], schema.fields); - if (hops.size > 0) entry.hops = hops; - } - plan.push(entry); - // Pre-compile to surface syntax errors at planning stage rather than - // per-row eval. Own-column dependency discovery is not used — CEL reads - // the row via `record.`; only the hops above are planned. - ExpressionEngine.compile(expr); + plan.push(formulaPlanEntry(f, def, schema.fields)); } else if (Array.isArray(requestedFields) && requestedFields.length > 0) { projected.add(f); } @@ -2893,6 +2904,124 @@ async function hydrateWriteFormulas( applyFormulaPlan(plan, records, execCtx, await permissionsFor(plan, records), onFault, await relatedFor?.(plan, records)); } +/** + * [#24619] The stored computed fields: every field that is NOT a `formula` + * and declares an `expression`. Such a field has a column, so a summary can + * roll it up (a summary aggregates stored columns, never an on-read formula), + * and its value is the engine's to compute on every write — the declared + * `expression` over the row the write stores. A client-sent value never + * stands: it is overwritten by the computed one, the way the console grid + * already recomputes the column client-side. Planned with the `formula` read + * plan's own entry builder ({@link formulaPlanEntry}), so the scale rounding + * and the one hop are the read's. + * + * An empty plan for an object declaring none — the common case — so a write + * on an ordinary object pays a field-name loop and nothing else. + */ +function planStoredExpressions(schema: any): FormulaPlanEntry[] { + const fields = schema?.fields; + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return []; + const plan: FormulaPlanEntry[] = []; + for (const [name, def] of Object.entries(fields as Record)) { + if (def && typeof def === 'object' && def.type !== 'formula' && def.expression) { + plan.push(formulaPlanEntry(name, def, fields)); + } + } + return plan; +} + +/** What {@link computeStoredExpressions} reads its permission map, fault sink and hops from. */ +interface StoredExpressionServing { + permissionsFor: (plan: FormulaPlanEntry[], records: readonly unknown[]) => Promise; + onFault?: FormulaFaultSink; + relatedFor?: (plan: FormulaPlanEntry[], records: ReadonlyArray>) => Promise; +} + +/** + * [#24619] Compute a stored-expression plan over each IMAGE — the row the + * write stores: the stored row merged with the patch on an update, the + * defaulted payload on an insert — and answer each image's computed values, + * index-aligned. Evaluates against shallow COPIES, so an image is never + * mutated; the caller writes the values onto the payload it hands the driver. + * An expression that does not evaluate computes `null`, the formula read + * path's answer, with the fault reported once through `onFault`. + */ +async function computeStoredExpressions( + plan: FormulaPlanEntry[], + images: ReadonlyArray>, + execCtx: ExecutionContext | undefined, + serving: StoredExpressionServing, +): Promise>> { + if (plan.length === 0 || images.length === 0) return images.map(() => ({})); + const scratch = images.map((image) => ({ ...image })); + applyFormulaPlan( + plan, scratch, execCtx, await serving.permissionsFor(plan, scratch), serving.onFault, + await serving.relatedFor?.(plan, scratch), + ); + return scratch.map((row) => { + const values: Record = {}; + for (const entry of plan) values[entry.name] = row[entry.name]; + return values; + }); +} + +/** + * [#24619] The one value a PREDICATE update can store for each stored + * computed field, judged over every matched row. `updateMany` writes ONE + * patch to all of them, so a field is set only when the patch moves it to the + * same value on every row; a patch that does not move it at all (it touches + * none of the expression's inputs) leaves the column alone, and a caller-sent + * value for it is taken out of the patch either way — it never stands. A patch + * that moves it to different values on different rows cannot be one + * statement, and is refused before anything is written: update those rows by + * id, where each is computed from its own stored row. + */ +function settleBulkStoredExpressions( + plan: FormulaPlanEntry[], + patch: Record, + before: ReadonlyArray>, + after: ReadonlyArray>, + object: string, +): Record { + const out: Record = { ...patch }; + const refused: FieldValidationError[] = []; + for (const entry of plan) { + delete out[entry.name]; + const moved = after.some((values, i) => !Object.is(values[entry.name], before[i]?.[entry.name])); + if (!moved) continue; + const first = after[0]?.[entry.name]; + if (after.every((values) => Object.is(values[entry.name], first))) { + out[entry.name] = first; + continue; + } + refused.push({ + field: entry.name, + code: 'invalid_value', + message: `'${entry.name}' on '${object}' is computed from its expression on every write, and this ` + + 'update changes its inputs on rows where it then computes different values, which one update of ' + + 'all the matched rows cannot store; update those rows by id, so each is computed from its own row', + }); + } + if (refused.length > 0) throw new ValidationError(refused); + return out; +} + +/** + * [#24619] Write a by-id update's computed values onto its patch: a key the + * caller (or a hook) sent is overwritten, and a key the patch does not carry + * is added only when the computed value differs from the stored one — a patch + * that moves none of the expression's inputs writes nothing extra. + */ +function applyStoredExpressionsToPatch( + patch: Record, + computed: Record, + prior: Record | null | undefined, +): void { + for (const [name, value] of Object.entries(computed)) { + if (Object.prototype.hasOwnProperty.call(patch, name) || !Object.is(prior?.[name], value)) patch[name] = value; + } +} + /** * Materialize ONE declared `formula` field against a record already in hand — * the read path's own evaluation, narrowed to a single field, with no round @@ -15661,6 +15790,24 @@ export class ObjectQL implements IObjectQLEngine { object, this.applyFieldDefaults(object, payloadRows[0] as Record, opCtx.context, nowSnap, defaultPermissions[0]), ); + // [#24619] The stored computed fields ({@link planStoredExpressions}), + // computed over the defaulted row BEFORE the beforeInsert hooks run, so + // a hook is handed the value the write stores — the #16344 ruling: + // 「交给生命周期钩子的记录,就是它打算持久化的那条记录。」 Computed + // again after the hooks and the strips (beside validation, below), + // because a hook may change an input, and that pass is the one stored. + const storedExpressionPlan = planStoredExpressions(this._registry.getObject(object)); + const storedExpressionServing: StoredExpressionServing = { + permissionsFor: (plan, records) => this.resolveFormulaPermissions(object, plan, records, opCtx.context, permissionResolution), + onFault: this.formulaFaultSink(object), + relatedFor: (plan, records) => this.resolveFormulaRelated(this._registry.getObject(object), plan, records, opCtx.context), + }; + if (storedExpressionPlan.length > 0) { + const defaultedRows = (isBatch ? defaultedData : [defaultedData]) as Array>; + const live = defaultedRows.filter((row, i) => undeclaredPerRow[i] === undefined && row != null && typeof row === 'object'); + const computed = await computeStoredExpressions(storedExpressionPlan, live, opCtx.context, storedExpressionServing); + live.forEach((row, k) => Object.assign(row, computed[k])); + } // [#22306] What each withheld key was SHOWN holding — its default, or // nothing — read before any hook runs, because the hooks mutate these // row objects in place. `handBackWithheldInsertReadonly` reads it to tell @@ -16255,6 +16402,15 @@ export class ObjectQL implements IObjectQLEngine { // has to exist here too. Gated on the schema actually declaring such a // predicate, so an object with only `record`-scoped requirements pays // nothing; batched, so N rows under M masters cost ONE header read. + // [#24619] The stored computed fields, computed over the FINAL rows — + // after every hook and strip, before validation judges them and the + // driver stores them — so the stored value is the declared expression + // over the stored row, whatever the caller or a hook put in the key. + if (storedExpressionPlan.length > 0) { + const liveForExpressions = rows.filter((row, i) => rowErrors[i] === undefined && row != null && typeof row === 'object'); + const computed = await computeStoredExpressions(storedExpressionPlan, liveForExpressions, opCtx.context, storedExpressionServing); + liveForExpressions.forEach((row, k) => Object.assign(row, computed[k])); + } const insertParentForRow = hasParentScopedRequiredWhen(schemaForValidation as any) ? await this.resolveMasterDetailParents(schemaForValidation, null, rows, opCtx.context) : undefined; @@ -16923,6 +17079,15 @@ export class ObjectQL implements IObjectQLEngine { // schema, and ONE lookup answering both is one fact rather than two that // can drift. const updateSchema = this._registry.getObject(object); + // [#24619] The stored computed fields ({@link planStoredExpressions}) + // and how their expressions are served — the write's own permission + // resolution and the read path's one hop, under this write's context. + const storedExpressionPlan = planStoredExpressions(updateSchema); + const storedExpressionServing: StoredExpressionServing = { + permissionsFor: (plan, records) => this.resolveFormulaPermissions(object, plan, records, opCtx.context, permissionResolution), + onFault: this.formulaFaultSink(object), + relatedFor: (plan, records) => this.resolveFormulaRelated(updateSchema, plan, records, opCtx.context), + }; const undeclared = undeclaredWriteFieldErrors( object, updateSchema as { fields?: unknown } | undefined, @@ -17268,6 +17433,26 @@ export class ObjectQL implements IObjectQLEngine { // permanently true here: it states the invariant, and the invariant // outlives this call site. if (priorRecord) hookContext.previous = coerceBooleanFields(updateSchema as any, priorRecord as any) as any; + // [#24619] The stored computed fields over the stored row merged + // with the caller's patch, BEFORE the beforeUpdate hooks, so a hook + // sees the expression's value over the PRE-strip row. That is not + // always the stored value: an input the `readonlyWhen` strip takes + // back out below still feeds this pass. Pass 2, after the hooks and + // both strips, is the value stored. + // + // Written onto the RAW payload (`opCtx.data`, the recording's + // target), never through `hookWrites.payload`: this pass runs + // inside the recording window, and an ENGINE write must not be + // recorded as a HOOK write (the rule at the hide pass above). And + // never onto a key the hide pass withheld: for those the read-only + // strips judge the caller's submission, reporting and, under + // `strictReadonlyWrites`, refusing it as they always have. + if (storedExpressionPlan.length > 0 && opCtx.data && typeof opCtx.data === 'object') { + const raw = opCtx.data as Record; + const [computed] = await computeStoredExpressions(storedExpressionPlan, [{ ...priorRecord, ...raw }], opCtx.context, storedExpressionServing); + for (const name of Object.keys(readonlyHiddenFromHooks ?? {})) delete computed[name]; + applyStoredExpressionsToPatch(raw, computed, priorRecord); + } // [commit 706ad0fcc] All three `beforeUpdate` dispatch sites inside the hide // window share one wrapper, so a hook that faults reaching THROUGH a // key this pass withheld names that key instead of surfacing the @@ -17826,6 +18011,15 @@ export class ObjectQL implements IObjectQLEngine { // "you sent a read-only field" should not depend on whether some // other field also failed a business rule. assertNoStrictDrops(); + // [#24619] The stored computed fields over the stored row merged + // with the FINAL payload — after the hooks and both strips, so a + // locked input the `readonlyWhen` strip took back out feeds the + // stored value, never the caller's — and before the statement. + if (storedExpressionPlan.length > 0) { + const patch = hookContext.input.data as Record; + const [computed] = await computeStoredExpressions(storedExpressionPlan, [{ ...priorRecord, ...patch }], opCtx.context, storedExpressionServing); + applyStoredExpressionsToPatch(patch, computed, priorRecord); + } // [#21663] The payload is FINAL here (see the seam below), so // every readonly value on it is one the driver will store: a // system writer's (the strip above never ran for it), a hook's, @@ -18096,6 +18290,19 @@ export class ObjectQL implements IObjectQLEngine { // caller is told before N rows are written with a column missing // — the failure mode a bulk write makes N times larger. assertNoStrictDrops(); + // [#24619] The stored computed fields on the predicate path, over + // the FINAL payload as on the by-id branch. One patch reaches + // every matched row, so {@link settleBulkStoredExpressions} keeps + // a computed value only when it is the same on every row, and + // refuses a patch that would compute different ones. The rows + // are the D7 read (`readPriorRows` is a memo — no second fetch). + if (storedExpressionPlan.length > 0) { + const matched = ((await readPriorRows()) ?? []).filter((row) => row != null && typeof row === 'object'); + const patch = hookContext.input.data as Record; + const before = await computeStoredExpressions(storedExpressionPlan, matched, opCtx.context, storedExpressionServing); + const after = await computeStoredExpressions(storedExpressionPlan, matched.map((row) => ({ ...row, ...patch })), opCtx.context, storedExpressionServing); + hookContext.input.data = settleBulkStoredExpressions(storedExpressionPlan, patch, before, after, object) as any; + } // [#21663] The predicate-path twin of the by-id second pass, at the // same point and for the same reason: the readonly values left on // the final payload are stored, so their SHAPE is judged — before diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 0e478b5c5fb..b5017655656 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1828,8 +1828,16 @@ export const FieldSchema = lazySchema(() => { })])).optional().describe("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: z.boolean().optional().describe('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.'), - /** Calculation — CEL formula. Plain string accepted for back-compat; build emits canonical envelope. */ - expression: EvaluatedExpressionInputSchema.optional().describe('Formula expression (CEL). e.g. F`record.amount * 0.1`'), + /** + * Calculation — CEL formula. Plain string accepted for back-compat; build emits canonical envelope. + * On a `formula` field it is computed on read and has no column. On any other (stored) field the + * engine computes it from the row the write stores and stores the result, so a `summary` can roll + * the column up and a client-sent value never stands: on every insert and every update by id (the + * stored row merged with the patch), and on a predicate update only when the patch moves one of the + * expression's inputs — the one value is stored when it is the same on every matched row, and the + * update is refused when it would differ between rows. + */ + expression: EvaluatedExpressionInputSchema.optional().describe('CEL expression the field\'s value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused).'), /** * The value type a `formula` field computes, declared at authoring (the way * Salesforce/Airtable carry a formula's result type). Lets consumers — dataset