Repository navigation
fix(objectql)!: under strictReadonlyWrites, a write whose value a stored expression replaces is refused, and nothing is written - #25086
Conversation
… replaces Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
📓 Docs Drift CheckThis PR changes 2 package(s): 1 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 — 145 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 20b027eca9197bba1e0dea98c36d5180d365b794 && git checkout 20b027eca9197bba1e0dea98c36d5180d365b794
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5050442bb91feff30aa8d4211daac6158331058a 54a1cf34b6544898ecb4606b18b83f33cf5eb2cc && git checkout -B drift-repro 5050442bb91feff30aa8d4211daac6158331058a && git merge --no-ff 54a1cf34b6544898ecb4606b18b83f33cf5eb2cc
node scripts/docs-audit/affected-docs.mjs --json 5050442bb91feff30aa8d4211daac6158331058a
|
…rict-stored-expression
…ed-expression pass Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
…rict-stored-expression
Contract reviewServed-tier: ① Derived judgmentsRound 2. Round 1, at head The blocker is fixed on all three paths. Every throwing stored-expression pass sits in a
At the moment of the throw, the Round 1's verified list still holds:
Non-blocking:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Seat re-read of the check-runs at landing: all 42 runs on |
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 「甲」, #60156303865025)What changes
strictReadonlyWritesrefuses every drop thatonFieldsDroppedreports. Since thestored_expressionreason landed (#24690, PR #247787532ea3c08), one reported drop was left out of the refusal: a caller value for a stored (non-formula) field that declares anexpression, which the engine replaces with the computed value. Under strict such a write completed and was only reported. The amended ruling 「甲」 (#60156303865025) ships the refusal in 18.x as a BREAKING minor, and triage re-timed this card to 18.x (6305854988).insertMany). The strict check inengine.insertnow judgesinsertDrops, 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 throwsReadonlyFieldRejectedError(operation: 'insert') before validation and before any driver dispatch, and no listener fires.insertManydelegates toinsert, so it refuses the whole call, as it does for the other strict drops.stored_expressionreport now goes throughemitDrop, the channel every strip reports through, and no longer callsnotifyDropdirectly. On both branches the oneassertNoStrictDrops()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.stored_expressionlast. Before, it was refused for the other drop alone.REASON_PHRASE.stored_expression(written by spec(data): once PR #24673 lands, a client value for a stored-expression field is overwritten with no report —DroppedFieldsEvent.reasonhas no value for "written, but recomputed", soonFieldsDropped/droppedFieldsstay silent #24690 ahead of this refusal) is reused. The per-reason message gains astored_expressionremedy (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 thecomputedremedy follows.packages/spec/src/contracts/data-engine.ts(strictReadonlyWrites) andpackages/spec/src/data/data-engine.zod.ts(DroppedFieldsEvent.reason) no longer namesstored_expressionas the arm strict does not refuse. The objectql comments inengine.tsandreadonly-strict-errors.tsfollow. No.describe()text moved:check:generatedreports 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
readonlyis still refused once, asreadonly. 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,insertManyand predicate update. Each refusal asserts thecode,drops(andoperation/fieldswhere 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_expressionin one refusal), and so is thereadonly-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 --noEmitclean, andcheck:test-typecheckOK (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 --commandsderived 94 commands from merge base688052e14, and all 94 exited 0. On the first pass,check:dual-build-cjs-loadsandcheck:type-check-debtprinted PREREQUISITE NOT MET (exit 3, nothing measured). I re-rancheck:type-check-debtunder the verify lock: its closure build ran and it re-measured OK,none above its recorded number. That full build also produced the missingdist/trees, andcheck:dual-build-cjs-loadsthen passed (112 entry points across 69 packages load).dispatch-gates --ranreconciles 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 blob3e8c0ec2bd1awithgit diff HEADempty. The suite imports./engine.jsfrom source, so no build is involved.emitDropletsstored_expressionpast strict. Three tests go red (update by id, predicate update, the union case), and the other 22 stay green.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/qadogfood 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/objectqlminor, with a BREAKING section that maps FROM (completed, reported) TO (refused, nothing written), gives the remedy, and carries the claim'sClause-②line and an ADR-0087not-required (no-migration-prescription)marker (the precedent shape ofbb4f5cc005).@objectstack/specis not listed: only TSDoc comments moved there, and no.describe()text or export did.Acceptance notes
content/docs/releases/) is not edited here. The changeset carries the text that release compilation reads..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(theexpressionon a stored field paragraph) still says a sent value is "not reported as dropped", which has been false since spec(data): once PR #24673 lands, a client value for a stored-expression field is overwritten with no report —DroppedFieldsEvent.reasonhas no value for "written, but recomputed", soonFieldsDropped/droppedFieldsstay silent #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.tsin disjoint hunks (lines 4628–6300 and 9043–12250). No open PR touchesdata-engine.zod.ts,contracts/data-engine.tsorreadonly-strict-errors.ts.Generated by Claude Code