Repository navigation
feat(spec,objectql): report a caller value a stored expression replaced, as DroppedFieldsEvent reason stored_expression - #24778
Conversation
…der DroppedFieldsEvent reason stored_expression (WIP) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
…hrough the composed stack (WIP) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
… stored, and add the changeset Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
…opped-fields-recomputed
…eldsEvent reason Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
…her than casting to any Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
📓 Docs Drift CheckThis PR changes 3 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 144 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9bdfc8677ae2f6792e880c7e891d1f224e213654 && git checkout 9bdfc8677ae2f6792e880c7e891d1f224e213654
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a7199ef9744edf0c3ef0e381bb95a328545a3da9 56cac787cbe56defaa3853fd178e7865bf6ea889 && git checkout -B drift-repro a7199ef9744edf0c3ef0e381bb95a328545a3da9 && git merge --no-ff 56cac787cbe56defaa3853fd178e7865bf6ea889
node scripts/docs-audit/affected-docs.mjs --json a7199ef9744edf0c3ef0e381bb95a328545a3da9
|
…opped-fields-recomputed
…donlyWrites instead of refusing it, deferring the refusal to 19.0 Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
|
Pin moved, for this PR's seat ·
This PR's landing is the Generated by Claude Code |
Contract reviewServed-tier: Read: card #24690 (body and every comment, claim ① Derived judgments
② Semver levelChangeset ③ Boundary flags
Implemented-by: VERDICT: PASS |
…in with stored_expression subtracted (objectui#13309) objectstack-ai/objectstack#24778 added a fifth arm, `stored_expression`, to `DroppedFieldsEventSchema.reason`. `_ReasonIsTheEnum` stated the union as exactly four members, so the Spec Shape Gate (objectui main against the spec built from objectstack main) reds on TS2344. Apply the objectui#11206 shape: subtract the new arm, a no-op at the pin; drop it at the pin bump that carries the arm. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB
…red expression replaces is refused, and nothing is written (#25086) Closes #25000 Clause-②: no (narrowing: under `strictReadonlyWrites`, a write whose value a stored expression replaces is refused instead of reported; it ships now under the amended 「甲」, #6015 `6303865025`) ## What changes `strictReadonlyWrites` refuses every drop that `onFieldsDropped` reports. Since the `stored_expression` reason landed (#24690, PR #24778 `7532ea3c08`), one reported drop was left out of the refusal: a caller value for a stored (non-`formula`) field that declares an `expression`, which the engine replaces with the computed value. Under strict such a write completed and was only reported. The amended ruling 「甲」 (#6015 `6303865025`) ships the refusal in 18.x as a BREAKING minor, and triage re-timed this card to 18.x (`6305854988`). - **Insert (single, batch, `insertMany`).** The strict check in `engine.insert` now judges `insertDrops`, the one event list that includes the stored-expression replacement, after the compute pass. Before, it judged only the strips' drops, ahead of that pass. A replaced value throws `ReadonlyFieldRejectedError` (`operation: 'insert'`) before validation and before any driver dispatch, and no listener fires. `insertMany` delegates to `insert`, so it refuses the whole call, as it does for the other strict drops. - **Update by id and predicate update.** The `stored_expression` report now goes through `emitDrop`, the channel every strip reports through, and no longer calls `notifyDrop` directly. On both branches the one `assertNoStrictDrops()` call now comes after the stored-expression pass. So under strict the write is refused before the statement and nothing is written. An object that declares no stored expression skips that pass, so its refusal happens exactly where it did before. - **One refusal names everything.** A payload that carries another strip's drop as well as a replaced value is now refused once, naming both, with `stored_expression` last. Before, it was refused for the other drop alone. - **Wording.** `REASON_PHRASE.stored_expression` (written by #24690 ahead of this refusal) is reused. The per-reason message gains a `stored_expression` remedy (leave the field out, or send the computed value). That remedy is spoken only when the reason is present, so every other refusal's message is unchanged byte for byte, by the same rule the `computed` remedy follows. - **Docs.** The TSDoc in `packages/spec/src/contracts/data-engine.ts` (`strictReadonlyWrites`) and `packages/spec/src/data/data-engine.zod.ts` (`DroppedFieldsEvent.reason`) no longer names `stored_expression` as the arm strict does not refuse. The objectql comments in `engine.ts` and `readonly-strict-errors.ts` follow. No `.describe()` text moved: `check:generated` reports all 14 artifacts current, so the generated reference docs have nothing to regenerate. **Unchanged:** a strict write that sends the computed value, or no value, is not refused. A stored expression field that is also `readonly` is still refused once, as `readonly`. Without strict, the write completes with the computed value and is reported. The error code is unchanged. ## Tests `packages/objectql/src/engine-stored-expression-dropped-fields.test.ts`: the strict block changes from "reported, not refused" to "refused, nothing written". It covers update by id, insert single, batch, `insertMany` and predicate update. Each refusal asserts the `code`, `drops` (and `operation`/`fields` where they matter), that the store is unchanged, and that the listener stayed silent. A non-strict control sits beside each refusal (the write succeeds and is reported), together with the strict controls for "computed value sent / no value". The union case is also covered (`readonly_when` + `stored_expression` in one refusal), and so is the `readonly`-only insert refusal. All runs were taken on the final code commit `a8e9d3611c`: - `pnpm --filter @objectstack/objectql test`: 455 files / 8569 tests passed. `pnpm --filter @objectstack/objectql typecheck`: `tsc --noEmit` clean, and `check:test-typecheck` OK (ledger held). - `pnpm --filter @objectstack/spec build && check:generated`: all 14 generated artifacts up to date. `spec test`: 705 files / 21424 passed (1 todo). `spec typecheck`: OK. `spec test:repo`: 72 files / 1237 passed. - `node scripts/pm/dispatch-gates.mjs --commands` derived 94 commands from merge base `688052e14`, and all 94 exited 0. On the first pass, `check:dual-build-cjs-loads` and `check:type-check-debt` printed PREREQUISITE NOT MET (exit 3, nothing measured). I re-ran `check:type-check-debt` under the verify lock: its closure build ran and it re-measured OK, `none above its recorded number`. That full build also produced the missing `dist/` trees, and `check:dual-build-cjs-loads` then passed (112 entry points across 69 packages load). `dispatch-gates --ran` reconciles 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN. The changeset gates are among the 94: `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests`. **Ablation.** The fix was committed first. Each leg went through `scripts/ablation-replace.mjs`, with an anchor hit of 1 to 0 and the blob proven back to the HEAD blob `3e8c0ec2bd1a` with `git diff HEAD` empty. The suite imports `./engine.js` from source, so no build is involved. - A, update paths: `emitDrop` lets `stored_expression` past strict. Three tests go red (update by id, predicate update, the union case), and the other 22 stay green. - B, insert: the strict check ignores a `stored_expression`-only list. Three tests go red (insert single, batch, `insertMany`), and the other 22 stay green. ## Producers measured No non-test caller in this repository passes `strictReadonlyWrites`: the examples, `packages/qa` dogfood and the runtime and REST doors pass none. The option is also not on the serializable options bag, so no wire caller can set it. No in-repo producer moves. In the objectql suite, the in-package strict tests on stored-expression objects (`engine-stored-expression-write.test.ts`, `engine-write-formula-hydration.test.ts`, `engine-formula-write-strip.test.ts`) stay green unchanged. ## Changeset `.changeset/25000-strict-stored-expression-refusal.md`: `@objectstack/objectql` `minor`, with a BREAKING section that maps FROM (completed, reported) TO (refused, nothing written), gives the remedy, and carries the claim's `Clause-②` line and an ADR-0087 `not-required (no-migration-prescription)` marker (the precedent shape of `bb4f5cc005`). `@objectstack/spec` is not listed: only TSDoc comments moved there, and no `.describe()` text or export did. ## Acceptance notes - The release-owned 18.x upgrade checklist (`content/docs/releases/`) is not edited here. The changeset carries the text that release compilation reads. - The pending `.changeset/24690-stored-expression-dropped-fields.md` (in the open Version Packages PR) says strict "is unchanged" and that the refusal is deferred to 19.0. That file is outside this card's claimed file surface, so this PR leaves it alone. The new changeset says it supersedes that bullet. The report names the one-line amendment for the seat to decide. - `content/docs/data-modeling/validation-rules.mdx` (the `expression` on a stored field paragraph) still says a sent value is "not reported as dropped", which has been false since #24690. That is a hand-written doc outside this card's surface, and it is noted in the report. Open PRs touching the same files were checked: #25072 and #24893 edit `engine.ts` in disjoint hunks (lines 4628–6300 and 9043–12250). No open PR touches `data-engine.zod.ts`, `contracts/data-engine.ts` or `readonly-strict-errors.ts`. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Closes #24690
Clause-②: yes (widening)
What
PR #24673 (
c0803b04d2) made the engine compute a stored (non-formula) field's declaredexpressionon every write, so a value the caller sends for such a field never stands. Before this PR nothing said so.onFieldsDroppedstayed silent, so the PATCH and/batchdoors answered with nodroppedFields, and a flow'supdate_recordreported success over a row that holds the computed value.Scope follows the maintainer's ruling 「甲」 (#6015 comment 6292005854, applied on decision card #24854): in 18.x only the loud half ships. That half is the new
reasonvalue, its emit, and its report. ThestrictReadonlyWritesrefusal is runtime behaviour that no conversion can carry, so it waits for 19.0.packages/spec/src/data/data-engine.zod.ts):DroppedFieldsEventSchema.reasongainsstored_expression, with its describe. The field IS written, with the computed value in place of the caller's, socomputed(aformulawith no column, whose key is dropped) would be untrue of it. The name follows the existing arms, which name the field's role (primary_key,computed). Thefieldsdescribe and the schema describe now also cover a field written with the computed value. Incontracts/data-engine.ts, thestrictReadonlyWritesTSDoc namesstored_expressionas the one arm outside its coverage in 18.x.engine.ts):replacedStoredExpressionFieldsreports a field when three things hold: the caller sent it, no other strip took it, and the value stored differs from the caller's. It runs on insert (single, batch, andinsertMany's per-rowdroppedFields), on update by id and on predicate update, in every context,isSystemincluded. A client that sends the computed value (the console grid's consistent save) or no value is told nothing. A value a staticreadonlystrip already took is reported once, asreadonly.notifyDrop). On insert, the strict check judges only the strips' own drops. Both strict checks sit exactly where they sit onmain, before the computation. So a strict write whose only drop is astored_expressionreplacement completes with the computed value and is reported toonFieldsDropped. A strict write that also carries a refusable drop is refused for that drop alone, writes nothing, and fires no listener.readonly-strict-errors.tsgains only theREASON_PHRASErow its exhaustive key type requires. No refusal carries that row in 18.x.crud-nodes.ts):DROPPED_REASON_LABELis an exhaustiveRecordover the reason union, so it needs the new row. For this reason the step warning says the requested value(s) were NOT stored, and that the write succeeded with the computed value(s). The other reasons keep their exact text.content/docs/references/**regenerated withcheck:generated --fix.Clause-②: yes (widening).@objectstack/specand@objectstack/objectqlareminor,@objectstack/service-automationispatch. Not breaking. It records that the strict refusal is deferred to 19.0 per 「甲」.Landing order: the objectui pin must move first
objectui's
STRIPPED_LINE(packages/app-shell/src/providers/writeWarningToast.ts) is aRecordkeyed byDroppedFieldsEvent['reason'].app-shellbuilds withtsc, andscripts/build-console.shinstalls this tree's@objectstack/*into the pinned objectui. The pin onmaintoday is616df6598b. It does not contain objectui PR 13233 (objectstack-ai/objectui#13178), which adds thestored_expressionrow ahead of the spec pin: merge commit8803ca54ac269643101f98dd0cc24ab25feb55a0, which is 36 commits ahead of616df6598band is on objectuimain. Landing this PR at today's pin would turn the full-batteryConsole Pin Gatered.The pin is not bumped here.
docs/releases-maintenance.mdsays the pin moves by hand, in its own PR, withscripts/bump-objectui.sh, to a revision chosen in an objectstack issue. This PR must not land before that pin PR does. The pin PR must:scripts/bump-objectui.sh 8803ca54ac269643101f98dd0cc24ab25feb55a0(or a later objectuimaincommit). It writes.objectui-shaand the@objectstack/consolechangeset for616df6598b..NEW.pnpm objectui:build, thennode scripts/gen-sdui-manifest-node.mjs. That rewritessdui.manifest.jsonandscripts/sdui-manifest.record.json, andscripts/check-sdui-manifest.mjsmust be green.stored_expression, so the pinned objectui compiles againstmain's spec both before and after this PR.Tests (on the head named in the latest report)
packages/objectql/src/engine-stored-expression-dropped-fields.test.tscovers by-id update (the PATCH door and each by-id/batchop), insert single, batch andinsertMany, and predicate update. It has controls for an equal value and an unsent value, and checks the single report when thereadonlystrip took the value. UnderstrictReadonlyWritesit checks three things. By id, on insert and on a predicate update, the write completes and is reported. A write that also has areadonly_whendrop is refused for that drop alone, writes nothing and fires no listener. An insert with a refusable drop is refused before the computation, carrying nostored_expressiondrop.packages/rest/src/rest-stored-expression-dropped-fields.test.tsruns through the composed stack (RestServer, then the protocol, thenObjectQL, then a realSqlDriveron better-sqlite3). PATCH answersdroppedFieldsand theX-ObjectStack-Dropped-Fieldsheader./batchtags the op index. Each has a consistent-value control.crud-dropped-fields.test.ts(service-automation) pins the new warning.data-engine.test.ts(spec) pins the five-value enum.scripts/ablation-replace.mjs. The tool restored and verified the file each time (blob equal to HEAD,git diff HEADempty).return [], and 12 of 30 objectql tests failed (first round).notifyDropwas routed back through the strict-or-listeneremitDrop, and the strict by-id report test failed (1 of 17).stored_expressionrow commented out ofDROPPED_REASON_LABEL, service-automationtscfailed with TS2741.Acceptance notes
Object.isis the comparison, as in the compute passes, over caller values normalized before the snapshot. Take a stored expression whose computed value has a different JS type from an equal value the caller sends, such as a date expression computing aDateagainst an ISO string: it would be reported. This was not measured.ImportRowResult.droppedFields(api/export),/batchand protocoldroppedFieldsdescribes still say "caller-supplied fields the engine LEGALLY strips". They carry the fifth reason through the shared enum, and their prose was left alone because it is outside this card's file surface.stored_expressionreplacement understrictReadonlyWrites, needs its own card, filed by the seat.Generated by Claude Code