You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit c22a069
Browse filesBrowse the repository at this point in the historyBrowse files
docs(spec): say what each write path does with a stored field's expression
The describe and TSDoc, the changeset's TO and hooks bullets and its
adr-0087 marker now state the predicate path (left alone when no input
moves, refused when the values differ) and what each path hands its hooks.
Claude-Session: https://claude.ai/code/session_01J2MKvkwHvmoNaHpU2wuqJu
Co-authored-by: Claude <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: .changeset/24619-stored-expression-computed-on-write.md
+4-3Lines changed: 4 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -7,15 +7,16 @@ feat(objectql)!: a stored field's declared `expression` is computed by the engin
7
7
8
8
Clause-②: no (narrowing)
9
9
10
-
<!-- adr-0087: not-required (no-migration-prescription) A narrowing of what a write of a stored field that declares `expression` is accepted as, at the engine's insert and update doors. No spec key, enum value, export, option or stored shape is added, removed, renamed or re-shaped: `FieldSchema.expression` keeps its schema and only its describe changes, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. A stored row whose value disagrees with its expression keeps it until the row is next written. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers this behaviour and this diff adds none (not registered or already-registered); and the change is a write-path behaviour, not only an interface or type change (not runtime-interface-only or type-surface-only). -->
10
+
<!-- adr-0087: not-required (no-migration-prescription) A narrowing of what a write of a stored field that declares `expression` is accepted as, at the engine's insert and update doors. No spec key, enum value, export, option or stored shape is added, removed, renamed or re-shaped: `FieldSchema.expression` keeps its schema and only its describe changes, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. A stored row whose value disagrees with its expression keeps it until an insert or an update by id writes that row, or a predicate update moves one of its expression's inputs; a predicate update that moves no input leaves it as it is. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers this behaviour and this diff adds none (not registered or already-registered); and the change is a write-path behaviour, not only an interface or type change (not runtime-interface-only or type-surface-only). -->
11
11
12
12
**BREAKING** accept-set narrowing, shipped as `minor` under the launch-window convention for breaking changes.
13
13
14
14
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`.
15
15
16
16
-**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.
17
-
-**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. It computes 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. The `before*` hooks are handed the computed value. A caller's value is overwritten, in every context. A patch that moves none of the expression's inputs stores the value the row already computes to. 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.
17
+
-**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.
18
+
-**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.
18
19
-**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.
19
-
-**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).
20
+
-**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`.
20
21
21
22
**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.
Copy file name to clipboardExpand all lines: content/docs/references/data/field.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -96,7 +96,7 @@ const result = CurrencyConfigSchema.parse(data);
96
96
|**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. |
97
97
|**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. |
98
98
|**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. |
99
-
|**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 on every insert and update from the row the write stores (the stored row merged with the patch) and stored, so a client-sent value never stands and a `summary` can roll the column up. |
99
+
|**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). |
100
100
|**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). |
101
101
|**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. |
102
102
|**language**|`string`| optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
Copy file name to clipboardExpand all lines: content/docs/references/data/object.mdx
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -262,7 +262,7 @@ const result = ApiMethod.parse(data);
262
262
|**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. |
263
263
|**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. |
264
264
|**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. |
265
-
|**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 on every insert and update from the row the write stores (the stored row merged with the patch) and stored, so a client-sent value never stands and a `summary` can roll the column up. |
265
+
|**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). |
266
266
|**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). |
267
267
|**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. |
268
268
|**language**|`string`| optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
@@ -618,7 +618,7 @@ const result = ApiMethod.parse(data);
618
618
|**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. |
619
619
|**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. |
620
620
|**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. |
621
-
|**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 on every insert and update from the row the write stores (the stored row merged with the patch) and stored, so a client-sent value never stands and a `summary` can roll the column up. |
621
+
|**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). |
622
622
|**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). |
623
623
|**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. |
624
624
|**language**|`string`| optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
0 commit comments