Skip to content

fix(objectql)!: a predicate delete honours deleteBehavior for every matched row: restrict refuses, master_detail cascades, set_null clears (#26871) - #26930

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-vdrn59
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-vdrn59

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26871

Clause-②: no (narrowing) — a predicate delete (engine.delete(object, { where, multi: true }), reached by a flow delete_record node with multi: true and a filter) refuses with 409 DELETE_RESTRICTED where it succeeded, when a row it matches is held by a restrict reference. Nothing is widened.
Metadata-protocol: none
Skills-docs: none — skills/objectstack-data/rules/relationships.md, content/docs/data-modeling/field-types.mdx and content/docs/getting-started/common-patterns.mdx already state deleteBehavior with no path exception; nothing in skills/** 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 scalar where.id) now runs the by-id delete's referential walk for every row it matches. The arm lives in delete() in packages/objectql/src/engine.ts; engine-delete-dispatch.ts only re-exports the dispatch predicate and is untouched. Seat ruling arm A: no second copy of the walk, no new option on engine.delete, no refusal of multi: true.

  • Cheap when there is nothing to walk. The arm asks the by-id branch's own question, planCascadeAtomicity(object). 'none' (no master_detail / lookup / tree points at the object) keeps today's single driver.deleteMany with the composed AST: no row read, no transaction.
  • Otherwise it reads the matched ids through the composed, row-scoped AST (fields: ['id'], the write's driver options), calls the existing cascadeDeleteRelations for 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 (a set_null on 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.
  • Atomicity is the by-id branch's. 'atomic' wraps the read, the walks and the delete in one transaction(), so a restrict on any matched row rolls back every cascade already done; 'split' runs unwrapped and warns once (warnCascadeNotAtomic), as by id.
  • Because the walk is cascadeDeleteRelations itself, 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 from main) applies to the predicate path with no further code.
  • The cascadeDeleteRelations docblock 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. The engine-cascade-delete-atomic.test.ts case "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): a restrict on one matched row refuses the whole delete with 409 DELETE_RESTRICTED and rolls back the cascade already done for another matched row; master_detail children cascade and set_null lookups clear for every matched row while an unmatched parent's rows stay; the walk and the delete ride ONE transaction handle; a self-referencing set_null that makes another row match the predicate does not get that row deleted; a detail restricting its own master refuses (the objectql: a lookup with 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 rule, inherited); zero matched rows write nothing. Plus the predicate control: no inbound relation, one deleteMany, 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, real ObjectKernel + ObjectQLPlugin + SqlDriver better-sqlite3 + AutomationServicePlugin): a flow delete_record with multi: true and a filter matching two parents. Restrict on one: the run fails, the node's answer carries innerRefusal.code DELETE_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_detail details are deleted, their set_null lookups 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 ONE deleteMany carrying 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

  • In-process multi: true delete callers, measured. plugin-auth removeOrganizationAuthStructure (sys_team_member, sys_user_permission_set), plugin-sharing deleteRowsForDeletedRecords / deleteRowsByIds / revokeRuleGrants* (sys_record_share), service-automation suspended-run-store (sys_automation_run, the claim compare-and-set and its capability probe): no field in packages/** or examples/** references any of those objects (searched both the string-literal and SystemObjectName constant spellings), so each still plans 'none' and keeps its single deleteMany and its count answer. lifecycle-service's production reap deletes row by row with a scalar where.id (dispatched by id even beside multi: true); its predicate fallback is reachable only by an engine with no find, a two-method test double. No caller depends on the walk being skipped on an object that has rules.
  • Stale prose, outside this PR's write scope, now false or half-true: the comment above removeOrganizationAuthStructure (plugin-auth/src/organization-delete.ts, "a multi-row delete runs no cascade"), the comment above lifecycle-service's per-id reap ("by-id deletes get referential cascade handling a filter-delete would bypass"), and the sys_organization predicate refusal (organizationPredicateDeleteRefusal's developerMessage "it runs no referential pass" and the [#24948] comment in delete()). The refusal itself is unchanged here; whether it stays is a seat question in the report.
  • Each matched row is walked as its own root, the same as the bulk deleteManyData door deleting by id row by row: a restrict held 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.
  • Cost. A predicate delete on an object with inbound relations now reads its matched ids and probes each relation per row, like N by-id deletes. No ceiling is added (none was asked); an object with no inbound relation pays nothing new.
  • Observed, not measured end to end: neither the predicate delete nor the predicate update recomputes roll-up summaries (priorRecord is bound only by id). Pre-existing, unrelated to the walk; noted, not filed.

Verification

All local readings below are on head 27b890d0 (the merge of main at 2720874a, which carries #26864, plus the last test commit), worktree objectstack-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 kept boss = 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
  • objectql full suite pnpm --filter @objectstack/objectql test: Test Files 475 passed (475), Tests 8855 passed (8855).
  • service-automation full suite pnpm --filter @objectstack/service-automation test: Test Files 214 passed, Tests 2746 passed (on the pre-merge head ace80cb2); on the merged head the new file plus crud-bulk-intent.test.ts: 12 passed.
  • pnpm --filter @objectstack/objectql typecheck exit 0, pnpm --filter @objectstack/service-automation typecheck exit 0 (both include check:test-typecheck).
  • eslint --no-inline-config packages/objectql packages/services/service-automation: exit 0, no output.
  • Ablation (scripts/ablation-replace.mjs, anchor if (plan === 'none') { replaced so every predicate delete takes the old single-deleteMany arm; 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-preflight found the marker in dist; flow file 2 failed. Restore: blob back to 913838237925 == HEAD, git diff HEAD empty; objectql rebuilt, preflight --absent clean over 14 files; flow file 2 passed.
Gates (derived by dispatch-gates --commands on the real diff)
  • 65 families derived; each run with its exit code captured before any pipe: 65 exit 0. pnpm check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3, no dist for 41 packages) and exit 0 after a full turbo run build (116 tasks); check:query-options-erasure first 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.
  • Everything else (the 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 test under the verify lock on 27b890d0 after a full build: VERDICT command-exit 0, Test Files 333 passed, 3 skipped (336), Tests 2706 passed, 20 skipped (2726).
Overlap

Session: session_01FEkQ5ptsvnYe5ZErgvP66h


Generated by Claude Code

…ter_detail and set_null (red first)

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>
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/organization-delete.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/organization-delete.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 33 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8bbdb0b46277cd975db8c395a7e1873f645a4894 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ae398a6a188adec62f25e283bad326daab58027b — the merge of head 22e33bb7b501c016d4ee0a8e9e9a0ae8f630cc7f into base 8bbdb0b46277cd975db8c395a7e1873f645a4894, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-27T22:44Z · head 22e33bb7b5 · #26871

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main, assignee os-project-manager. The body carries Fixes #26871, with Clause-②: no (narrowing) and Metadata-protocol: none line-initial; the changeset .changeset/26871-predicate-delete-referential-walk.md repeats them and carries the BREAKING accept-set note and its remedy.
    • check-governed-merges --pr 26930 reads NOT governed: 0 of 7 paths hit the register, 514 changed lines.
    • The claim (6452356793) names this PR's branch and the same Clause-② reading.
  2. Against the seat ruling (arm A, 6454565591):
    • The predicate arm of delete() calls the existing cascadeDeleteRelations for every matched id. It adds no second walk, no new option on engine.delete, and no refusal of multi: true.
    • It asks the by-id branch's own planCascadeAtomicity. 'none' keeps the single deleteMany, with no row read and no transaction. 'atomic' wraps the id read, the walks and the $in delete in one transaction(). 'split' warns once, as by id.
    • The rows deleted are the rows walked (by id, batches of 500), so a walk's own set_null cannot widen the delete. A case pins that.
    • 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 is inherited with no further code, and pinned.
  3. The reword the ruling asked for: 2888073 rewords the [Decision] Deleting an organization once organization_id is NOT NULL: refuse while it still owns records, or delete them with it #24948 organization refusal's developerMessage and three comments whose stated reason this PR made false (seat ruling 6454565591); no behaviour change. The seat read its code lines: the only non-comment change is the developerMessage string. The user-facing message is unchanged, and the one test pinning the refusal asserts by id in it.
  4. The CI fix: 22e33bb drops object from the flow test's driver.find query (DriverQuery has no such key). It is one line, in a test.
  5. Red first and ablation (the dev's):
    • On base a51ba639, engine-cascade-delete-atomic.test.ts failed 4 of 18, because the restrict case resolved 2 instead of rejecting.
    • The real-kernel flow test (ObjectKernel + SqlDriver better-sqlite3 + AutomationServicePlugin, delete_record with multi: true) failed 2 of 2.
    • With the 'none' anchor forced on, the atomic file failed 5 and the flow file 2, after a rebuild with the dist marker found. After the restore the blob equals HEAD, the preflight --absent is clean, and the flow file passes 2 of 2.
  6. Tests on the head: objectql 8,855 of 8,855; plugin-auth 3,795 passed and 10 skipped; the flow file 2 of 2. Type-check exits 0 for service-automation, objectql and plugin-auth.
  7. Dogfood: run on 27b890d, the last behaviour change: 2,706 passed, 20 skipped, no failures. Later commits are prose and a test-file type fix.
  8. Gates:
    • Locally 66 of 66 derived families exit 0 on 22e33bb, the dual-build family after a full build, with 0 NOT-MEASURED.
    • At this stamp every check-run on the head reads success or skipped: 34 success, 11 skipped, none failed or in progress. GitHub reads the PR clean.
  9. Overlap: git merge-tree exits 0 against main. The file lists of this PR and the other open server PR, fix(metadata-protocol,runtime,app-shell)!: refuse a save or draft publish naming a package that is not installed #26920, share no path.
  10. Out of scope: the report notes, without an end-to-end measurement, that a predicate delete and a predicate update do not recompute roll-up summaries, because priorRecord is bound only on the by-id path. That gap is older than this PR. The seat files it as its own card; this PR does not wait on it.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 27, 2026 22:45
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 27, 2026 22:45
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit b2b3124 Oct 27, 2026
46 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-vdrn59 branch October 27, 2026 23:19
os-zhuang pushed a commit that referenced this pull request Oct 28, 2026
…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
os-warren pushed a commit that referenced this pull request Oct 28, 2026
…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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants