Skip to content

Commit 4a2ca57

Browse files
docs(data-modeling): field-types and validation-rules say what expression means on a stored field, beside its formula meaning (#24734)
Fixes #24716 Clause-②: no ## What changes Docs only: two hand-written data-modeling pages, 11 lines added, 0 removed. No package code, no generated page, no gate. - `content/docs/data-modeling/field-types.mdx`: a short subsection, `expression` on a stored field, under `### formula`, with one example. - `content/docs/data-modeling/validation-rules.mdx`: one paragraph under its `### formula` section, linking to that subsection. Both pages use the terms of the landed `FieldSchema.expression` describe (`packages/spec/src/data/field.zod.ts:1840`), and both link to the generated [`FieldSchema`](https://github.com/objectstack-ai/objectstack/blob/main/content/docs/references/data/field.mdx) reference instead of restating it. The describe is byte-identical between `d20bf9b09b` (the dispatch's reading) and this branch's base `ec2694dc19`, and PR #24673 (`c0803b04d2`) is the last commit to touch it. ## The two passages ### `field-types.mdx` Before: the page's only `expression` text is the `formula` table row (`:411`, "CEL calculation expression") and the `formula` example (`:415`). After: inserted between the `formula` example and `### summary` (now `:418`–`:425`): ```mdx #### `expression` on a stored field `expression` is not only a `formula` key. On a field of any other type, the engine computes it from the row each write stores and **stores** the result in the field's column, so a `summary` can roll the column up. It is computed on every insert and every update by id (the stored row merged with the patch). On a predicate update it is computed only when the patch moves an input: one value is stored for every matched row, and the update is refused (`VALIDATION_FAILED`, an `invalid_value` error on the field) when the rows would compute different values. Update those rows by id instead. A value a client sends for the field never stands: it is overwritten by the computed one, with no refusal and no dropped-field report. The full contract is the `expression` row of [`FieldSchema`](/docs/references/data/field). // Stored, so a parent `summary` can sum it. A client-sent amount is overwritten. Field.currency({ label: 'Amount', expression: 'record.quantity * record.unit_price' }) ``` On the page, the example is a `typescript` fence. ### `validation-rules.mdx` Before: `:354` ("**Required.** CEL formula expression"), `:357` ("Read-only. Value computed at runtime from `expression`. Not directly writable.") and the `:552` row (`formula` → `expression`). All three describe a `formula` only. After: inserted between the `formula` example and `### summary` (now `:368`): ```mdx **`expression` on a stored field.** A field of any other type may declare `expression` too, and it validates differently from a `formula`. The engine computes the value on every insert and every update by id, and stores it. A value a client sends for the field is not refused and not reported as dropped: it is overwritten by the computed one. The one refusal is a predicate update that moves an input: when the matched rows would compute different values, the update is refused with `VALIDATION_FAILED` and an `invalid_value` error naming the field, and nothing is written. See [`expression` on a stored field](/docs/data-modeling/field-types#expression-on-a-stored-field) and the `expression` row of [`FieldSchema`](/docs/references/data/field). ``` The existing sentences (`field-types.mdx:407`–`:415`, `validation-rules.mdx:354`, `:357`, `:552`) are not rewritten. Each sits under a `formula` heading or in the `formula` row, and each page now states the stored meaning right beside it, so none of them reads as "only `formula` has an expression". ## Measured facts (engine and spec at base `ec2694dc19`) **1. Is a stored field with an `expression` read-only to a client write?** No. A client-sent value is overwritten, not refused, and not reported. - Insert (single, batch, `insertMany`) and update by id: the computed value is written over the caller's key, and the write succeeds. No `onFieldsDropped` event fires, so there is no `droppedFields` entry, and `strictReadonlyWrites` does not refuse it. Two engine facts show why: - The only computed-field strip, `stripComputedWriteFields` (`packages/objectql/src/engine.ts:2274`), selects `def?.type === 'formula'` only. - `RUNTIME_OWNED_FIELD_TYPES` is `['autonumber']` (`field.zod.ts:291`). - Pinned in `packages/objectql/src/engine-stored-expression-write.test.ts`: - `:142`–`:152`: `amount` 2999.4 is sent and 299.94 stored, and the events equal only `[quantity] readonly_when`. - `:173`–`:176`: a wrong amount is overwritten. - `:183`–`:188`: on insert, none, a wrong and the right amount each store the product. - Predicate update: `settleBulkStoredExpressions` (`engine.ts:2979`) deletes the caller's key from the patch. - It sets the column only when the patch moves it, to one value on every matched row. - It throws `ValidationError` (`VALIDATION_FAILED`, field error `invalid_value`) before any write when the computed values would differ between rows. - Pinned at test `:251`–`:271`. - Only when the field also declares `readonly: true` does an update by id report the caller's value as a `readonly` drop, and refuse it under `strictReadonlyWrites` (test `:229`–`:247`). The pages do not say this: it is beyond the describe and the card's ask. **2. Which field types may carry a stored `expression`?** Every type other than `formula`. Nothing narrows the type. - Engine: `planStoredExpressions` (`engine.ts:2921`) plans every field with `def.type !== 'formula' && def.expression`. It has no type list. - Spec: `FieldSchema.safeParse` of each `FieldType` member with `expression: 'record.quantity * record.unit_price'` accepts all 49 and refuses 0. This was a one-off probe with tsx over `packages/spec/src` at the base, and it is not kept as a file. - Lint: the field-formula pass (`judgeFieldFormula`, `packages/lint/src/validate-expressions.ts:2873`) judges any field's `expression` as a record-scoped CEL `value`, with no type condition. So both pages say "a field of any other type". ## The dispatch's two mechanism hypotheses 1. **The docs gates and a stored-field example.** `check:doc-formula-expressions` admits a fenced site by structure only. Rule A is a `Field.*({ expression })` call. Rule B is a literal carrying `type: 'formula'`. To measure it, the gate's own `extractFormulaExpressions` / `looksLikeFormulaBlock` / `judge` were imported from a temporary truncated copy, deleted after the run: - A raw literal is not admitted and trips no tripwire, even with bare references. The input was `{ name: 'amount', label: 'Amount', type: 'currency', expression: 'quantity * unit_price' }`, and the probe read admitted 0, tripwire false. It would pass unjudged. - `Field.currency({ label: 'Amount', expression: 'quantity * unit_price' })` is admitted and judged red (1 error). - So the example uses the `Field.currency(...)` spelling, the one both example apps write for this field. The gate judges it: `field-types.mdx` sites went from 1 at base to 2 at head, the new one at `:424`, with 0 errors. - `check:doc-authoring` is green on it. 2. **Is `validation-rules.mdx` the wrong page?** Not for the validation half. Its `formula` section states the validation behaviour ("Read-only … Not directly writable."), and a stored field differs exactly there. Its value is overwritten, not refused, and the one refusal is the predicate update. So that page's paragraph carries the validation behaviour and points to `field-types.mdx` for the rest. The card's two-page acceptance is kept. ## Verification (head `9dc3aba0af`, every gate run after the final commit) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 40 commands from the 2-path change set against merge base `ec2694dc1`. - All 40 exit 0. On the first pass, 5 refused before measuring: - `check:doc-formula-expressions`, `check:doc-security-posture`, `check:skill-examples` and `check:docs-transcript-drift` exited 3, PREREQUISITE NOT MET, because packages were unbuilt. - Spec `check:docs` exited 1, its own refusal on the missing `gen:schema` tree. - Two builds ran under `scripts/pm/os-verify-lock.sh`: `turbo run build --filter='@objectstack/lint...'`, then `--filter='@objectstack/client-react...' --filter='@objectstack/client...'`. Both read `VERDICT command-exit 0`. All five then re-ran to exit 0. - `--ran`: "✓ dispatch-gates --ran: 40 derived famil(ies) accounted for — 40 run, 0 NOT-MEASURED (a DERIVED zero — all 40 recorded an exit code and none of them is 3)." - Verdict lines: - `check:doc-formula-expressions`: "23 record-scoped formula example(s) across 476 files / 1394 TS blocks judged clean by @objectstack/formula." - `check:doc-anchors`: "539 internal #fragment link(s) across 423 source file(s) all resolve to a real heading". This includes the new `#expression-on-a-stored-field` link. - Spec `check:docs`: "227 generated files in sync with packages/spec". - `check:skill-examples`: "262 prose examples type-check across 3 surface(s)". - `check:nul-bytes`: "OK … no raw ASCII control bytes". - `skip-changeset`: `content/` is in the workflow's "releases nothing" set. Of the 73 published `package.json` files, none lists `content/` in `files[]`, and no build script copies `content/docs/data-modeling/` into a package. - Not run locally: the CI-only families (the type-check lanes and the `Test Core` shards). They are CI's. ## Acceptance notes - `packages/spec/src/data/object.form.ts:363`: the object designer's field grid shows `expression` only when `data.type == 'formula'`, with help text "CEL formula expression". A stored `expression` is therefore neither shown nor editable in that grid. The field designer, `field.form.ts:340`, shows `expression` for every type, with help text "CEL expression to calculate this field (makes it read-only)". Under the engine the value is not client-settable, but the field is not `readonly`: a sent value is neither reported nor refused. Neither form was driven in a browser. This is an observation, not filed. Carrier: none. - `check:doc-formula-expressions` does not admit a raw non-`formula` literal carrying `expression`, so a raw stored-expression example with bare references would pass unjudged (measured above). The present population is 0. A one-line grep of `content/` and `skills/` (generated references excluded) for a `type:` literal beside `expression:` finds 12 lines, all `formula` or cron. This is an observation. No gate is added, per the dispatch ruling. - `origin/main` moved two commits past the base (`0904d856e3`, `d0514e6863`). Neither touches these pages, the describe or the engine, so the branch was not merged. --- _Generated by [Claude Code](https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b27f8de commit 4a2ca57

2 files changed

Lines changed: 11 additions & 0 deletions

File tree

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,15 @@ Calculated field using a CEL expression (see [Expressions](/docs/data-modeling/f
415415
{ name: 'total', label: 'Total', type: 'formula', expression: 'record.quantity * record.unit_price', returnType: 'number' }
416416
```
417417

418+
#### `expression` on a stored field
419+
420+
`expression` is not only a `formula` key. On a field of any other type, the engine computes it from the row each write stores and **stores** the result in the field's column, so a `summary` can roll the column up. It is computed on every insert and every update by id (the stored row merged with the patch). On a predicate update it is computed only when the patch moves an input: one value is stored for every matched row, and the update is refused (`VALIDATION_FAILED`, an `invalid_value` error on the field) when the rows would compute different values. Update those rows by id instead. A value a client sends for the field never stands: it is overwritten by the computed one, with no refusal and no dropped-field report. The full contract is the `expression` row of [`FieldSchema`](/docs/references/data/field).
421+
422+
```typescript
423+
// Stored, so a parent `summary` can sum it. A client-sent amount is overwritten.
424+
Field.currency({ label: 'Amount', expression: 'record.quantity * record.unit_price' })
425+
```
426+
418427
### `summary`
419428
Roll-up summary from child records.
420429

‎content/docs/data-modeling/validation-rules.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,8 @@ application-level emptiness check is needed.
365365
}
366366
```
367367

368+
**`expression` on a stored field.** A field of any other type may declare `expression` too, and it validates differently from a `formula`. The engine computes the value on every insert and every update by id, and stores it. A value a client sends for the field is not refused and not reported as dropped: it is overwritten by the computed one. The one refusal is a predicate update that moves an input: when the matched rows would compute different values, the update is refused with `VALIDATION_FAILED` and an `invalid_value` error naming the field, and nothing is written. See [`expression` on a stored field](/docs/data-modeling/field-types#expression-on-a-stored-field) and the `expression` row of [`FieldSchema`](/docs/references/data/field).
369+
368370
### `summary`
369371

370372
| Property | Type | Default | Validation Behavior |

0 commit comments

Comments
 (0)