Repository navigation
fix(objectql): a tree field's deleteBehavior takes effect on a parent delete - #23055
Conversation
…eteBehavior One family predicate (statesDeleteSemantics: master_detail, lookup, tree) shared by cascadeRelationBehavior, the collection and dependents walks that read it, and planCascadeAtomicity. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…ee-delete-behavior
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 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 6f3877adac2979bd0c06b471d2b781d49fb64027 && git checkout 6f3877adac2979bd0c06b471d2b781d49fb64027
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin df7a137923be503bf6fea15a6fc944d3ba06df6f 85abb2ed96cc910f5b29d5757f32fa933bd1ceb6 && git checkout -B drift-repro df7a137923be503bf6fea15a6fc944d3ba06df6f && git merge --no-ff 85abb2ed96cc910f5b29d5757f32fa933bd1ceb6
node scripts/docs-audit/affected-docs.mjs --json df7a137923be503bf6fea15a6fc944d3ba06df6f
|
Contract reviewServed-tier: Inputs read, and nothing else: card #22957 (body; triage 6119365636; claim 6124337059; report 6125920806; seat review 6126018973), PR #23055 (body, the 3-file list, the net diff against ① Derived judgments
② Semver levelThe card's thread asks two things; both are answered from the repo's rules, not from lane memory.
③ Boundary flags
Adopted by Implemented-by: VERDICT: PASS |
Fixes #22957
Clause-②: no
The engine's referential delete walk now follows
treefields. ThedeleteBehaviorthe spec declares on atree(materialized asset_nullwhen the author writes none) takes effect on a by-id delete of the parent. This follows triage's ruling (comment 6119365636): the runtime honours the declared behaviour, and the spec's materialization stays as it is.What changed
packages/objectql/src/engine.ts: one family predicate,statesDeleteSemanticsoverDELETE_SEMANTICS_REFERENCE_TYPES(master_detail,lookup,tree), replaces themaster_detail/lookupliteral at both readers of the delete walk that tested the family. Atreeresolves like alookup: an authored value is used as written, and an absent one resolves toset_null.packages/objectql/src/engine-cascade-delete-tree.test.ts: 18 pins, listed below..changeset/22957-tree-delete-behavior.md:patchfor@objectstack/objectql.Readers of the walk (read on
origin/main9f4b652e3a)cascadeRelationBehaviormaster_detail/lookupliteralcollectCascadeDeleteSet(phase 1)cascadeRelationBehaviorwalkReferencingRelations(phase 2, the dependents walk)cascadeRelationBehaviorplanCascadeAtomicityand itsreferenceCarrierOfprobemaster_detail/lookupliteralmaster_detailcoercion tocascade, themaster_detail+set_nullwarning, and the requiredset_nullescalation torestricttreetakes thelookupbranch, so a required tree'sset_nullescalates like a required lookup's (pinned)referenceCarrierOfcall inbuildSummaryIndexmaster_detail/lookupliteralMeasured before and after
os verify --app examples/app-showcase/objectstack.config.ts --delete-behaviors --jsonBefore:
9f4b652e3a. After: objectql built from2030d882ac.engine.tsis byte-identical from there through this head,85abb2ed96.hardFailuresshowcase_field_zoo.f_tree(declaredset_null)holderValue: null)showcase_invoice.contact, refused by an app validation rule), 11 excluded, 3 not-derivableReal driver: ObjectQL +
SqlDriver(better-sqlite3), a scratch probe (not committed)set_nullmaterialized byFieldSchema.parsenullnullcascade: deleteafrom root, a, b, c (a chain) plus a2 and an unrelated pairadeleteda,b,cdeleted; root, a2 and the pair keptrestrictDELETE_RESTRICTED409 naming the tree's own object; both rows kept; the leaf then deletescascade, a 15-level chaincascade, a corrupt cycle (root's parent is its grandchild) and a row that is its own parentmultiple: truenull(read single-valued)cascadewith abeforeDeleteveto on a deep nodelookupdefaultset_null,lookupcascade,master_detaildefaultlookupandmaster_detailrestrictDELETE_RESTRICTED409The four questions the dispatch asked
multiple: true.FieldSchemarefusesmultiple: trueon atreeat parse: it is declarable only on select, lookup, user, file and image.lookuphas a multi-valued path (the$containsprobe and member removal). Atreecannot reach that path through the contract. The walk's multi-value arbiterdeclaredMultiValuedasks the spec'sisMultiValueField, which reads a raw-registered multitreeas single-valued, as every other engine site does. Nothing new was added for it.set_nullon a baretree, and the test pins that as its premise. The engine reads the field raw, and an absent key also resolves toset_null. Both are pinned.MAX_CASCADE_DEPTH(10) bounds onlyplanCascadeAtomicity's schema walk.cascadeDeleteRelationshas adepthparameter, but the nested deletes never advance it (the one call site passes the default), so the cascade over rows is bounded by its set.collectCascadeDeleteSetexpands each record once, and the deleting walk skips a record it has already entered. Pinned: a 15-level chain is deleted whole, and a corrupt cycle and a self-parent both terminate.'none'and opened no transaction. It now plans'atomic', so a refusal deep in a subtree rolls back the nodes deleted before it.Pins (
engine-cascade-delete-tree.test.ts, in-file driver with snapshot rollback)code+status+dependentObject, and both rows are kept; a leaf deletes.set_nullescalates toDELETE_RESTRICTED.multiple: trueon a tree; a raw registration is read single-valued.restrict(2 cases); an object nothing references opens no transaction.Ablation (from the committed head
5ce34e0670, throughscripts/ablation-replace.mjs)Each leg's anchor hit 1 to 0 and the blob changed. After each leg the restore read blob == HEAD and
git diff HEADwas empty.treedropped fromDELETE_SEMANTICS_REFERENCE_TYPESgave 11 failed and 7 passed. Red: all 11 tree-behaviour pins. Green: the 2 parse premises, the restrict leaf delete, and the 4 CONTROL tests.planCascadeAtomicity's reader put back to the old literal gave 2 failed and 16 passed: the subtree's single-commit assertion and the deep-veto rollback. So the atomicity widening is pinned on its own.Tests
pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/engine-cascade-delete-tree.test.ts: 18 passed, at5ce34e0670.vitest run --project local --maxWorkers=2) ran at2030d882acplus the then-uncommitted required pin: 406 files and 7953 tests passed, 1 failed. The failure was the required pin as first written: its fixture referenced a row that did not exist, and the engine's write-time reference check refused it. The fixture was corrected and passes in the single-file run above.pnpm --filter @objectstack/objectql typecheck: exit 0, coveringtscandcheck:test-typecheck. The new file is intsconfig.test.json's program (--listFilescount 1).@objectstack/objectql's public surface is unchanged (no export, no type), so no importing package owes a test.Gates
dispatch-gates --commandson this head85abb2ed96derives 68 commands: the dispatched 57 plus 11 that the changeset brings in. All 68 exit 0. The--ranreconciliation reads 68 derived, 68 run, 0 NOT-MEASURED, and that zero is derived from the recorded exit codes.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET because 8 packages outside this diff had nodist/. They were built (all turbo cache hits), and the gate re-ran with exit 0.Deviations
engine-cascade-delete-*suites, not a real driver.packages/objectqllists no driver package among its dependencies, and adding one (topackage.json,pnpm-lock.yamland the test-source-alias ledger) is outside the claim's file surface. The real-driver evidence is the showcase verify (sqlite-wasm, through REST and the system door) and the scratchSqlDriverprobe above.origin/maingained two docs-only commits (ADR text and anchor JSON) after the base. They were merged into this branch as85abb2ed96; no package changed.Acceptance notes
treethat omitsreferenceis still not walked.ObjectSchemaaccepts it as a self-reference (therefuseForeignTreeReferencerule: the reference stays optional), and the parse materializesset_nullon it. ButreferenceCarrierOfreads no carrier, andreferenceTargetOfcalls a referencelesstreetargetless. Measured on this head through REST:DELETEanswered 200 and the child kept the deleted id. Repairing it is a spec decision: which of the two readings is the contract, and whether a referenceless tree's target should resolve to its owning object. That is not a consumer-side fallback to add here. Reported to the seat.packages/spec/src/data/field.zod.ts(thedeleteBehaviorTSDoc and the.overwriteblock) still say that the engine reads the key only behind amaster_detail/lookupguard. That now includestree. These are comments only. Carrier: none. Noted, not filed.deleteBehavioron auserfield still parses and is still not walked. That is the spec's existing line foruser, and this PR leaves it untouched.Generated by Claude Code