Repository navigation
fix(objectql)!: a predicate delete honours deleteBehavior for every matched row: restrict refuses, master_detail cascades, set_null clears (#26871) - #26930
Conversation
Claude-Session: https://claude.ai/code/session_01FEkQ5ptsvnYe5ZErgvP66h Co-authored-by: Claude <noreply@anthropic.com>
…ter_detail and set_null (red first) Claude-Session: https://claude.ai/code/session_01FEkQ5ptsvnYe5ZErgvP66h Co-authored-by: Claude <noreply@anthropic.com>
… matched row (#26871) Claude-Session: https://claude.ai/code/session_01FEkQ5ptsvnYe5ZErgvP66h Co-authored-by: Claude <noreply@anthropic.com>
…wered filter on its id read Claude-Session: https://claude.ai/code/session_01FEkQ5ptsvnYe5ZErgvP66h Co-authored-by: Claude <noreply@anthropic.com>
…26871) Claude-Session: https://claude.ai/code/session_01FEkQ5ptsvnYe5ZErgvP66h Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5ptsvnYe5ZErgvP66h Co-authored-by: Claude <noreply@anthropic.com>
…rule Claude-Session: https://claude.ai/code/session_01FEkQ5ptsvnYe5ZErgvP66h Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 33 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 ae398a6a188adec62f25e283bad326daab58027b && git checkout ae398a6a188adec62f25e283bad326daab58027b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8bbdb0b46277cd975db8c395a7e1873f645a4894 22e33bb7b501c016d4ee0a8e9e9a0ae8f630cc7f && git checkout -B drift-repro 8bbdb0b46277cd975db8c395a7e1873f645a4894 && git merge --no-ff 22e33bb7b501c016d4ee0a8e9e9a0ae8f630cc7f
node scripts/docs-audit/affected-docs.mjs --json 8bbdb0b46277cd975db8c395a7e1873f645a4894 |
…efusal, team-member order and per-id reap keep (#26871) Prose only. The predicate delete now runs the referential walk, so the three comments and the organization refusal's developerMessage that gave 'a predicate delete runs no cascade' as their reason now state the reason that still holds. Claude-Session: https://claude.ai/code/session_01FEkQ5ptsvnYe5ZErgvP66h Co-authored-by: Claude <noreply@anthropic.com>
…Query Claude-Session: https://claude.ai/code/session_01FEkQ5ptsvnYe5ZErgvP66h Co-authored-by: Claude <noreply@anthropic.com>
席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
…ix for #27037) The case seeds RELATION_FILTER_ID_CAP (1,000) rows and deletes 1,001: 2.3 s alone, past the default 5 s on a loaded full-suite queue shard, which ejected #26960, #27011 and #27020 (#27037). The slowdown itself is #27004's (since #26930 a 1,001-row predicate delete is about 18x slower); this only budgets for it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QUs3Bshfmr8srPY21BVS4u
…ix for #27037) The case seeds RELATION_FILTER_ID_CAP (1,000) rows and deletes 1,001: 2.3 s alone, past the default 5 s on a loaded full-suite queue shard, which ejected #26960, #27011 and #27020 (#27037). The slowdown itself is #27004's (since #26930 a 1,001-row predicate delete is about 18x slower); this only budgets for it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QUs3Bshfmr8srPY21BVS4u (cherry picked from commit 6674ba8)
Requested by Leehom · project thread
Fixes #26871
Clause-②: no (narrowing) — a predicate delete (
engine.delete(object, { where, multi: true }), reached by a flowdelete_recordnode withmulti: trueand a filter) refuses with 409DELETE_RESTRICTEDwhere it succeeded, when a row it matches is held by arestrictreference. Nothing is widened.Metadata-protocol: none
Skills-docs: none —
skills/objectstack-data/rules/relationships.md,content/docs/data-modeling/field-types.mdxandcontent/docs/getting-started/common-patterns.mdxalready statedeleteBehaviorwith no path exception; nothing inskills/**or the docs taught that a predicate delete skips it.维护者速读
multi: true+ filter,流程的delete_record批量节点走的就是它)现在对每一条命中的记录执行与按 id 删除相同的引用检查:restrict拒绝整次删除,master_detail子记录级联删除,set_null引用被清空。deleteMany,完全跳过deleteBehavior,流程批量删父记录会留下孤儿子记录和悬空引用,而同样的记录逐条删除会被拒绝或级联。声明了却不兑现。deleteMany、不读行、不开事务。原来能成功的批量删除,若命中行被restrict引用,现在会 409 拒绝(整体回滚)。回滚即 revert 本 PR。packages/objectql/src/engine.ts的delete()谓词分支([#26871]段);packages/objectql/src/engine-cascade-delete-atomic.test.ts的3b段;packages/services/service-automation/src/builtin/delete-record-predicate-referential-walk.integration.test.ts。pnpm --filter @objectstack/objectql exec vitest run src/engine-cascade-delete-atomic.test.ts,预期 19 条全绿,其中[#26871]段 6 条。What changes
ObjectQL.delete()'s predicate arm (multi: true, no scalarwhere.id) now runs the by-id delete's referential walk for every row it matches. The arm lives indelete()inpackages/objectql/src/engine.ts;engine-delete-dispatch.tsonly re-exports the dispatch predicate and is untouched. Seat ruling arm A: no second copy of the walk, no new option onengine.delete, no refusal ofmulti: true.planCascadeAtomicity(object).'none'(nomaster_detail/lookup/treepoints at the object) keeps today's singledriver.deleteManywith the composed AST: no row read, no transaction.fields: ['id'], the write's driver options), calls the existingcascadeDeleteRelationsfor each id, then deletes exactly those rows by id (id $in, batches of 500). By id rather than by re-running the predicate, because the walk's own writes (aset_nullon a self-reference) can move a row into or out of the predicate; the rows deleted are the rows walked. The answer is still the count the driver reports.'atomic'wraps the read, the walks and the delete in onetransaction(), so arestricton any matched row rolls back every cascade already done;'split'runs unwrapped and warns once (warnCascadeNotAtomic), as by id.cascadeDeleteRelationsitself, fix(objectql)!: a restrict reference refuses the delete of the record it protects even when the referencing row also cascades from it (#26718) #26864's own-cascade restrict rule (merged into this branch frommain) applies to the predicate path with no further code.cascadeDeleteRelationsdocblock line "Only runs for single-id deletes — multi/predicate deletes skip cascade" described the defect and now says the walk runs for every matched row. Theengine-cascade-delete-atomic.test.tscase "opens no transaction when the object HAS dependents declared but the delete is by predicate" pinned the defect too; it is replaced by the predicate control on an object nothing references, and the dependents case moves to the new section, which pins one transaction.Tests
packages/objectql/src/engine-cascade-delete-atomic.test.ts, new section 3b ([#26871], 6 cases, snapshot-rollback driver): arestricton one matched row refuses the whole delete with 409DELETE_RESTRICTEDand rolls back the cascade already done for another matched row;master_detailchildren cascade andset_nulllookups clear for every matched row while an unmatched parent's rows stay; the walk and the delete ride ONE transaction handle; a self-referencingset_nullthat makes another row match the predicate does not get that row deleted; a detail restricting its own master refuses (the objectql: a lookup withdeleteBehavior: "restrict"is overridden when the same parent is also the child's master_detail target — deleting the parent cascade-deletes the child instead of refusing 409 #26718 rule, inherited); zero matched rows write nothing. Plus the predicate control: no inbound relation, onedeleteMany, no transaction, no warning. The fake driver learned$in(the by-id-set delete).packages/services/service-automation/src/builtin/delete-record-predicate-referential-walk.integration.test.ts(new, realObjectKernel+ObjectQLPlugin+SqlDriverbetter-sqlite3 +AutomationServicePlugin): a flowdelete_recordwithmulti: trueand a filter matching two parents. Restrict on one: the run fails, the node's answer carriesinnerRefusal.codeDELETE_RESTRICTED, and every row (both parents, the detail and the lookup the walk reached first) is still there, counted out of the driver. No restrict: both parents'master_detaildetails are deleted, theirset_nulllookups are cleared, and the unmatched parent's rows stay.packages/objectql/src/engine-nested-relation-lowering.test.ts: the "every verb that takes a where" pin recorded ONEdeleteManycarrying the lowered filter. The ledger object has relations pointing at it, so the predicate delete now reads its matched ids first; the pin now asserts that read carries the lowered filter and that nothing is written when nothing matches. The driver still never receives the nested form.Acceptance notes
multi: truedelete callers, measured.plugin-authremoveOrganizationAuthStructure(sys_team_member,sys_user_permission_set),plugin-sharingdeleteRowsForDeletedRecords/deleteRowsByIds/revokeRuleGrants*(sys_record_share),service-automationsuspended-run-store(sys_automation_run, the claim compare-and-set and its capability probe): no field inpackages/**orexamples/**references any of those objects (searched both the string-literal andSystemObjectNameconstant spellings), so each still plans'none'and keeps its singledeleteManyand its count answer.lifecycle-service's production reap deletes row by row with a scalarwhere.id(dispatched by id even besidemulti: true); its predicate fallback is reachable only by an engine with nofind, a two-method test double. No caller depends on the walk being skipped on an object that has rules.removeOrganizationAuthStructure(plugin-auth/src/organization-delete.ts, "a multi-row delete runs no cascade"), the comment abovelifecycle-service's per-id reap ("by-id deletes get referential cascade handling a filter-delete would bypass"), and thesys_organizationpredicate refusal (organizationPredicateDeleteRefusal'sdeveloperMessage"it runs no referential pass" and the[#24948]comment indelete()). The refusal itself is unchanged here; whether it stays is a seat question in the report.deleteManyDatadoor deleting by id row by row: arestrictheld by ANOTHER matched row of the same delete refuses (it is outside that root's cascade set). Unchanged from deleting the same rows by id.priorRecordis bound only by id). Pre-existing, unrelated to the walk; noted, not filed.Verification
All local readings below are on head
27b890d0(the merge ofmainat2720874a, which carries #26864, plus the last test commit), worktreeobjectstack-issue-26871, shared container.Red first, on base a51ba63 (before the fix)
pnpm --filter @objectstack/objectql exec vitest run src/engine-cascade-delete-atomic.test.ts: 4 failed / 14 passed. The restrict case:promise resolved "2" instead of rejecting; the self-reference case: the report keptboss = root(no set_null ran).pnpm --filter @objectstack/service-automation exec vitest run src/builtin/delete-record-predicate-referential-walk.integration.test.ts(objectql dist built from base): 2 failed. Restrict:the run fails on the restrict: expected true to be false; cascade: the matched parent's detail row was still there.Green, and the ablation
pnpm --filter @objectstack/objectql test: Test Files 475 passed (475), Tests 8855 passed (8855).pnpm --filter @objectstack/service-automation test: Test Files 214 passed, Tests 2746 passed (on the pre-merge headace80cb2); on the merged head the new file pluscrud-bulk-intent.test.ts: 12 passed.pnpm --filter @objectstack/objectql typecheckexit 0,pnpm --filter @objectstack/service-automation typecheckexit 0 (both includecheck:test-typecheck).eslint --no-inline-config packages/objectql packages/services/service-automation: exit 0, no output.scripts/ablation-replace.mjs, anchorif (plan === 'none') {replaced so every predicate delete takes the old single-deleteManyarm; anchor 1 to 0, blob 913838237925 to d31446d5dd01): objectql atomic file 5 failed / 14 passed (the five walk cases; the zero-row case and the controls stay green); objectql rebuilt,ablation-dist-preflightfound the marker in dist; flow file 2 failed. Restore: blob back to 913838237925 == HEAD,git diff HEADempty; objectql rebuilt, preflight--absentclean over 14 files; flow file 2 passed.Gates (derived by dispatch-gates --commands on the real diff)
pnpm check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET (exit 3, no dist for 41 packages) and exit 0 after a fullturbo run build(116 tasks);check:query-options-erasurefirst counted one new test-code erasure (the new flow test's driver read), fixed by typing it, then exit 0; the teardown self-test needed its pinned fixture commit fetched.dispatch-gates --ran: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.check:*farm,Test Core,Test UI, the rest of the required nine) is CI's: see this PR's check-runs.Dogfood battery (owed by the narrowing arm)
pnpm --filter @objectstack/dogfood testunder the verify lock on27b890d0after a full build: VERDICT command-exit 0, Test Files 333 passed, 3 skipped (336), Tests 2706 passed, 20 skipped (2726).Overlap
deleteBehavior: "restrict"is overridden when the same parent is also the child's master_detail target — deleting the parent cascade-deletes the child instead of refusing 409 #26718) merged tomainwhile this was in flight;mainis merged into this branch, and the predicate arm inherits its own-cascade restrict rule (pinned above).git merge-treeof this head againstorigin/mainat8074feb6is clean.src/engine.ts(23,903 lines, +7,501 in 30 days) along itsObjectQLseams, one move-only slice per PR #25522 cut 17) was open at the start; its merge-tree with this branch was clean. No body the [maintainer] refactor(objectql): splitsrc/engine.ts(23,903 lines, +7,501 in 30 days) along itsObjectQLseams, one move-only slice per PR #25522 split moved is edited here:delete()andcascadeDeleteRelationsboth still live inengine.ts.Session:
session_01FEkQ5ptsvnYe5ZErgvP66hGenerated by Claude Code