Skip to content

Commit c22a069

Browse files
committed
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>
1 parent 84b68de commit c22a069

5 files changed

Lines changed: 16 additions & 13 deletions

File tree

‎.changeset/24619-stored-expression-computed-on-write.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ feat(objectql)!: a stored field's declared `expression` is computed by the engin
77

88
Clause-②: no (narrowing)
99

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). -->
1111

1212
**BREAKING** accept-set narrowing, shipped as `minor` under the launch-window convention for breaking changes.
1313

1414
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`.
1515

1616
- **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.
1819
- **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`.
2021

2122
**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.

‎content/docs/references/data/field.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ const result = CurrencyConfigSchema.parse(data);
9696
| **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. |
9797
| **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. |
9898
| **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). |
100100
| **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). |
101101
| **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. |
102102
| **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |

‎content/docs/references/data/object.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ const result = ApiMethod.parse(data);
262262
| **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. |
263263
| **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. |
264264
| **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). |
266266
| **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). |
267267
| **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. |
268268
| **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
@@ -618,7 +618,7 @@ const result = ApiMethod.parse(data);
618618
| **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. |
619619
| **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. |
620620
| **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). |
622622
| **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). |
623623
| **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. |
624624
| **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |

0 commit comments

Comments
 (0)