Computed fields
A computed field is derived rather than supplied. The engine evaluates an expression and either returns the result on read or stores it in a real column.
A field is computed when it declares computed:, script:, or a compute: block. The
expression is evaluated by the script runtime — it is not pushed into SQL, so SQL syntax in
a computed expression does not evaluate.
compute-strategy | Evaluated | Persisted |
|---|---|---|
virtual | on read, after rows are scanned | no |
materialized | on create and update, before the statement runs | yes, into a real column |
Any token other than virtual — including a typo such as virtaul — resolves to
materialized, and so does omitting the key on a field declared computed through
computed: or script:. The one exception: a field whose only compute declaration is a
nested compute: block with neither compute-strategy: nor triggers: is virtual.
fields: - name: takehome type: int computed: "basic + bonus - (professionaltax + incometax)" compute-strategy: materialized
- name: display_name type: string nullable: true compute: language: expr expression: "first_name + ' ' + last_name" compute-strategy: virtualBindings available to the expression are row (the row or payload as a map), entity (the
entity name), tenant and user_id. The materialized path additionally promotes every
payload key to the top level, so qty * unit_price resolves directly.
language: accepts expr (the default), cel, js, javascript, lua, starlark,
go and wasm. The body goes in expression: for a one-liner or script: for a full
program; function: names the entry point for the module languages.
The data.* service namespace — data.query, data.count, data.aggregate and the rest —
is injected only for js / javascript, lua, starlark and go. An expr, cel or
wasm body cannot reach it.
Two further limits are worth knowing before you rely on computed values:
- The engine never emits
GENERATED ALWAYS AS … STORED. Derived values are the engine’s responsibility, so anything writing to the table with direct SQL bypasses computation. - An expression that fails to evaluate does not fail the request. The error is recorded in
the response metadata under
computed:error:<field>ormaterialized:error:<field>and the field is left as-is. evaluation time.
Choosing a language
Section titled “Choosing a language”| Language | Reaches data.* | Use it for |
|---|---|---|
expr (default) | no | Arithmetic, concatenation, conditionals over the current row |
cel | no | The same, when you already write CEL elsewhere |
js / javascript | yes | Anything needing other rows, loops, or real string work |
lua, starlark, go | yes | Same capability as JavaScript; pick on team familiarity |
wasm | no | Precompiled modules |
The line that matters: expr and cel see only the row in front of them. The moment a field
depends on another entity, it has to be one of the scripting languages, and it has to be
materialized with triggers.
Examples — same row, with expr
Section titled “Examples — same row, with expr”expr is the default and the right choice for most computed fields. No language key needed.
Line total on an invoice line:
- name: line_total type: double computed: "qty * unit_price"A total with tax, rounded to whole currency units:
- name: gross_total type: double computed: "round((qty * unit_price) * (1 + tax_rate), 2)"Full name, tolerating a missing middle name:
- name: full_name type: string nullable: true compute-strategy: virtual computed: "first_name + (middle_name != nil ? ' ' + middle_name : '') + ' ' + last_name"Take-home pay:
- name: takehome type: double computed: "basic + bonus - (professional_tax + income_tax + provident_fund)"A margin percentage, guarding the divide:
- name: margin_pct type: double nullable: true computed: "revenue > 0 ? ((revenue - cost) / revenue) * 100 : 0"That guard is not decoration. An expression that fails does not fail the request — it records the error and leaves the field alone — so a divide-by-zero produces a silently absent value, not a loud failure. Guard the arithmetic rather than relying on the error surfacing.
A display label built from parts:
- name: display_ref type: string nullable: true compute-strategy: virtual computed: "'INV-' + string(year) + '-' + string(sequence)"Examples — predicates, with cel
Section titled “Examples — predicates, with cel”CEL suits a boolean or a small classification, and reads well when the condition is the point.
Whether an order qualifies for free shipping:
- name: free_shipping type: bool compute: language: cel expression: "row.subtotal >= 5000.0 && row.country == 'IN'"A risk band:
- name: risk_band type: string nullable: true compute-strategy: virtual compute: language: cel expression: > row.credit_score >= 750 ? 'low' : (row.credit_score >= 600 ? 'medium' : 'high')Overdue, from a date already on the row:
- name: is_overdue type: bool compute: language: cel expression: "row.due_date < row.as_of_date && row.status != 'paid'"Note the shape: CEL reads through row., while a materialized expr body can name payload keys
directly. Both bindings exist in both — row is always available.
Examples — other entities, with JavaScript
Section titled “Examples — other entities, with JavaScript”Once a field depends on rows the current entity does not hold, you need a scripting language, a
materialized strategy, and triggers so the stored value is refreshed when the source changes.
How many completed orders a customer has:
- name: completed_orders type: int nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | return data.count("orders", { customer_id: row.id, status: "completed" }); triggers: - entity: orders on: [create, update, delete] affected_via: customer_id when_columns: [customer_id, status]when_columns is what keeps this cheap: an order whose shipping address changes does not enqueue a
refresh, because neither listed column moved.
Lifetime value, summing a related entity:
- name: lifetime_value type: double nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | const orders = data.query("orders", { customer_id: row.id, status: "completed" }); return orders.reduce((sum, o) => sum + (o.total || 0), 0); triggers: - entity: orders on: [create, update, delete] affected_via: customer_id when: language: expr expression: "row.status == 'completed'"The when predicate is a second gate on top of on: a draft order never enqueues a refresh at all,
so the queue only carries work that can change the answer.
A denormalised label from a parent, kept current:
- name: customer_name type: string nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | const c = data.find_by_id("customers", row.customer_id); return c ? c.name : null; triggers: - entity: customers on: [update] affected_via: id when_columns: [name]This is the classic reason to reach for materialization: the join is paid once on write instead of on every read, and the trigger keeps it honest when the customer is renamed.
A stock position across warehouses:
- name: total_on_hand type: int nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | const rows = data.query("inventory", { sku: row.sku }); return rows.reduce((n, r) => n + (r.on_hand || 0), 0); triggers: - entity: inventory on: [create, update, delete] affected_via: sku when_columns: [sku, on_hand]Aggregation without pulling rows:
- name: avg_rating type: double nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | const res = data.aggregate("reviews", { filter: { product_id: row.id, published: true }, avg: "rating" }); return res ? res.avg : null; triggers: - entity: reviews on: [create, update, delete] affected_via: product_id when_columns: [product_id, rating, published]Prefer data.aggregate to querying and summing in the script when you only need the number — it
does the work in the database rather than moving every row into the runtime.
Picking a strategy
Section titled “Picking a strategy”| Ask | Answer |
|---|---|
| Does it depend only on this row, and is it cheap? | virtual — nothing to keep in sync |
| Is it read far more often than written? | materialized |
| Does it depend on another entity? | materialized, with triggers. There is no other option |
| Do you need to filter or sort by it in a query? | materialized — a virtual field has no value in the column to filter on |
That last row is the one people hit late. A virtual field is computed after rows are scanned, so
the database cannot use it in a WHERE or an ORDER BY.
Cross-entity refresh triggers
Section titled “Cross-entity refresh triggers”A materialized field can declare the source entities whose mutations invalidate it. Same entity computation needs no triggers.
| Key | Meaning |
|---|---|
entity | Source entity. Required. |
on | create, update, delete. Empty matches all three. |
affected_via | Single source column selecting the target row. |
affected_via_cols | Composite key columns into the target row. |
affected_via_expr | Script expression deriving the target key. |
when_columns | Update-only: skip the refresh when none of these columns changed. |
when | Script predicate gate. Empty always enqueues. |
Exactly one of affected_via, affected_via_cols and affected_via_expr may be set;
more than one is a load error, as is a trigger naming an unknown entity or sitting on a
field that is not materialized.
fields: - name: order_count type: int nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | return data.count("orders", { customer_id: row.id, status: "completed" }); triggers: - entity: orders on: [create, update, delete] affected_via: customer_id when_columns: [customer_id, status] when: language: expr expression: "row.status == 'completed'"Cycle detection
Section titled “Cycle detection”The trigger graph is checked when the schema loads. Two things happen there, and neither is a runtime concern:
A cyclic graph is refused. If orders triggers a refresh on customer.order_total, and
customers triggers one back on orders.customer_tier, the schema fails to load and the error
names the full cycle path. This is a load-time refusal rather than a runtime guard because a cycle
has no correct behaviour to fall back to — it would refresh forever.
A missing trigger is warned about. If a compute script appears to reference an entity that is
not in its own triggers: list, the loader says so:
compute: language: javascript script: | return data.count("orders", { customer_id: row.id }); triggers: - entity: invoices # orders is referenced but not triggered on: [create] affected_via: customer_idThat is the “I forgot to add the trigger” case, and it is worth a warning rather than an error because the reference may be deliberate. Left alone, the field simply goes stale — no error, no failed request, just a number that stops moving. That is the hardest kind of bug to notice, which is why the loader looks for it.
See also
Section titled “See also”- Fields — every other field key
- Materialization — how the refresh queue behaves at runtime, and its limits
- Transforms — changing a supplied value rather than deriving one
- Scripted endpoints — the
data.*namespace a script body can call