From 94c61707c79312113fbc4bf06ca7f7693c898987 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 24 Jun 2026 12:21:33 +0800 Subject: [PATCH 1/2] docs(guides): formula fields declare result type via `returnType`, not `type` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Field.formula`'s config is `FieldInput = Omit, 'type'>`, so passing `type: 'currency' | 'text' | 'number' | …` is a type error — and untyped (in an .mdx example) it OVERRIDES `type:'formula'`, which makes objectql skip the field entirely: the engine computes a formula virtual field from its `expression` only (engine.ts), so the field silently never evaluates. Several guide examples taught that broken pattern; some also used a `formula:` key that isn't on `FieldSchema` (the source key is `expression`). Fix all examples to declare the computed value's type with `returnType` (`number`/`text`/`boolean`/`date` — added in framework#2255) and put the CEL source on `expression`; restore the valid `scale` I had dropped; add a Callout in formula.mdx explaining a formula field's `type` is always `formula`. Co-Authored-By: Claude Opus 4.8 --- content/docs/guides/business-logic.mdx | 51 +++++++++++++------------- content/docs/guides/formula.mdx | 14 ++++++- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/content/docs/guides/business-logic.mdx b/content/docs/guides/business-logic.mdx index 5d4f8a26fc..d6f949a59d 100644 --- a/content/docs/guides/business-logic.mdx +++ b/content/docs/guides/business-logic.mdx @@ -386,8 +386,8 @@ Formula fields are CEL. Use ternary `cond ? a : b` for conditionals and // Tiered pricing Field.formula({ label: 'Discount Tier', - type: 'text', - formula: ` + returnType: 'text', + expression: ` record.amount > 1000000 ? "Platinum" : record.amount > 500000 ? "Gold" : record.amount > 100000 ? "Silver" : "Bronze" @@ -397,8 +397,8 @@ Field.formula({ // Status indicator Field.formula({ label: 'Health Score', - type: 'text', - formula: ` + returnType: 'text', + expression: ` record.is_active && record.days_since_contact < 30 ? "Healthy" : record.days_since_contact < 90 ? "At Risk" : "Critical" `, @@ -411,49 +411,50 @@ Field.formula({ // Days until close Field.formula({ label: 'Days to Close', - type: 'number', - formula: 'daysBetween(record.close_date, today())', + returnType: 'number', + expression: 'daysBetween(record.close_date, today())', }) // Contract end in 30 days Field.formula({ label: 'Expiring Soon', - type: 'boolean', - formula: 'record.status == "active" && daysBetween(record.end_date, today()) <= 30', + returnType: 'boolean', + expression: 'record.status == "active" && daysBetween(record.end_date, today()) <= 30', }) // Age in days Field.formula({ label: 'Age (Days)', - type: 'number', - formula: 'daysBetween(today(), record.created_date)', + returnType: 'number', + expression: 'daysBetween(today(), record.created_date)', }) ``` ### Financial Calculations ```typescript -// Gross margin +// Gross margin (computes a number; a formula's type is always 'formula' — +// declare the value type with returnType, and decimal places with scale) Field.formula({ label: 'Gross Margin %', - type: 'percent', - formula: 'record.revenue > 0 ? ((record.revenue - record.cost) / record.revenue) * 100 : 0', + returnType: 'number', + expression: 'record.revenue > 0 ? ((record.revenue - record.cost) / record.revenue) * 100 : 0', scale: 2, }) // Weighted pipeline Field.formula({ label: 'Weighted Amount', - type: 'currency', - formula: 'record.amount * (record.probability / 100)', + returnType: 'number', + expression: 'record.amount * (record.probability / 100)', scale: 2, }) // Total with tax Field.formula({ label: 'Total with Tax', - type: 'currency', - formula: 'record.subtotal * 1.0825', // 8.25% tax + returnType: 'number', + expression: 'record.subtotal * 1.0825', // 8.25% tax scale: 2, }) ``` @@ -464,22 +465,22 @@ Field.formula({ // Full name Field.formula({ label: 'Full Name', - type: 'text', - formula: 'record.first_name + " " + record.last_name', + returnType: 'text', + expression: 'record.first_name + " " + record.last_name', }) // Uppercase Field.formula({ label: 'Code', - type: 'text', - formula: 'upper(record.sku)', + returnType: 'text', + expression: 'upper(record.sku)', }) // Has a company email Field.formula({ label: 'Internal', - type: 'boolean', - formula: 'endsWith(lower(record.email), "@example.com")', + returnType: 'boolean', + expression: 'endsWith(lower(record.email), "@example.com")', }) ``` @@ -609,8 +610,8 @@ flows: [ // Formula: Lead score Field.formula({ label: 'Lead Score', - type: 'number', - formula: ` + returnType: 'number', + expression: ` (record.rating == "hot" ? 30 : record.rating == "warm" ? 20 : 10) + (record.annual_revenue > 1000000 ? 25 : 0) + (record.number_of_employees > 500 ? 20 : 0) + diff --git a/content/docs/guides/formula.mdx b/content/docs/guides/formula.mdx index 51963933f9..52947564ce 100644 --- a/content/docs/guides/formula.mdx +++ b/content/docs/guides/formula.mdx @@ -89,7 +89,7 @@ export const Invoice = ObjectSchema.create({ titleFormat: tmpl`Invoice {{record.invoice_no}} – {{record.customer.name}}`, fields: { total: Field.formula({ - type: 'currency', + returnType: 'number', expression: F`record.subtotal + record.subtotal * record.tax_rate`, }), po_number: Field.text({ @@ -103,6 +103,18 @@ export const Invoice = ObjectSchema.create({ }); ``` + +**A formula field's `type` is always `formula`** — that is the key the runtime +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'`); it is inferred and +stamped automatically by the AI build path, and consumers (dashboard measures, +formatting) read it instead of re-parsing the expression. The CEL source is the +**`expression`** key (the `formula` alias is normalized to it on save). + + The three CEL tagged-template helpers — `cel`, `F` (formula alias), `P` (predicate alias) — are interchangeable; pick whichever reads best at the call site. `cron` and `tmpl` produce their respective envelopes. From ec1b9625eb0517d6389fd013bad5afaba751c94c Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 24 Jun 2026 12:22:34 +0800 Subject: [PATCH 2/2] chore: empty changeset for docs-only formula guide fix Co-Authored-By: Claude Opus 4.8 --- .changeset/docs-formula-returntype.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/docs-formula-returntype.md diff --git a/.changeset/docs-formula-returntype.md b/.changeset/docs-formula-returntype.md new file mode 100644 index 0000000000..42ba7f3a49 --- /dev/null +++ b/.changeset/docs-formula-returntype.md @@ -0,0 +1,4 @@ +--- +--- + +docs: formula guides declare a formula result type via `returnType` (not the forbidden `type`); CEL source on `expression`.