Repository navigation
Commit b2b3124
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XG5f9ptoMdvkj41yUX3JGGd)_
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, #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 #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.
<details><summary>Red first, on base a51ba63 (before the fix)</summary>
- `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.
</details>
<details><summary>Green, and the ablation</summary>
- 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 9138382 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
9138382 == HEAD, `git diff HEAD` empty; objectql rebuilt, preflight
`--absent` clean over 14 files; flow file 2 passed.
</details>
<details><summary>Gates (derived by dispatch-gates --commands on the
real diff)</summary>
- 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.
</details>
<details><summary>Dogfood battery (owed by the narrowing arm)</summary>
- `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).
</details>
<details><summary>Overlap</summary>
- #26864 (#26718) merged to `main` while this was in flight; `main` is
merged into this branch, and the predicate arm inherits its own-cascade
restrict rule (pinned above). `git merge-tree` of this head against
`origin/main` at `8074feb6` is clean.
- #26881 (#25522 cut 17) was open at the start; its merge-tree with this
branch was clean. No body the #25522 split moved is edited here:
`delete()` and `cascadeDeleteRelations` both still live in `engine.ts`.
</details>
Session: `session_01FEkQ5ptsvnYe5ZErgvP66h`
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5ptsvnYe5ZErgvP66h)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent b1b153a commit b2b3124
7 files changed
Lines changed: 479 additions & 35 deletions
File tree
- .changeset
- packages
- objectql/src
- lifecycle
- plugins/plugin-auth/src
- services/service-automation/src/builtin
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
71 | 76 | | |
72 | 77 | | |
73 | 78 | | |
| |||
404 | 409 | | |
405 | 410 | | |
406 | 411 | | |
407 | | - | |
408 | | - | |
409 | | - | |
410 | | - | |
411 | | - | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
412 | 418 | | |
413 | 419 | | |
414 | | - | |
| 420 | + | |
415 | 421 | | |
| 422 | + | |
416 | 423 | | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
417 | 574 | | |
418 | 575 | | |
419 | 576 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
203 | 203 | | |
204 | 204 | | |
205 | 205 | | |
206 | | - | |
207 | | - | |
208 | | - | |
209 | | - | |
210 | | - | |
211 | | - | |
212 | | - | |
213 | | - | |
214 | | - | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
215 | 221 | | |
216 | 222 | | |
217 | 223 | | |
| |||
0 commit comments