Repository navigation
Commit 31a6ca7
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XKKhNWtvAHeAwPmZfHrR6PA)_
Fixes #26718
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — the docs and skills already say a `restrict` refuses
the parent delete while a referencing row exists
(`content/docs/data-modeling/field-types.mdx`,
`skills/objectstack-data/rules/relationships.md`); this makes the
runtime honour that on one more path, and nothing taught changes.
## 维护者速读
- **改了什么**:按 id 删除父记录时,若某条明细既通过 master_detail 归属该父记录、又用 `deleteBehavior:
'restrict'` 引用同一父记录(或级联更深一层的孙记录这样引用),现在先判定 restrict,返回 409
`DELETE_RESTRICTED`,什么都不删。
- **为什么改**:以前级联把这条明细算进“本次要删的集合”,集合内的行一律不触发
restrict,于是明细被静默级联删除,作者声明“不可删”的数据丢失(p1 数据丢失)。
- **风险与回滚**:只收窄这一种删除;集合外的 restrict、普通级联、set_null、必填 lookup 的升级 restrict
行为不变。回滚即撤销本 PR。#22305 定下的“同一父记录下兄弟之间的 restrict 不拦”保持原样,是否也要拦见 Acceptance
notes 的待决问题。
- **先看**:`packages/objectql/src/engine.ts` 的
`refuseRestrictsInsideOwnCascade`(新增的写前判定)与 `collectCascadeDeleteSet`
里记录级联边的几行;`packages/runtime/src/cascade-delete-restrict-same-parent-real-driver.integration.test.ts`。
- **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run
--project local
src/cascade-delete-restrict-same-parent-real-driver.integration.test.ts`(先
build objectql),预期 7 条全绿;在 main 上同一文件 4 红。
- **需要你做的**:无,席位落地。
## What changes
`ObjectQL.cascadeDeleteRelations` (the by-id delete's referential walk,
`packages/objectql/src/engine.ts`) gains a read-only step between its
two phases. After it collects the cascade set and before its first
write, `refuseRestrictsInsideOwnCascade` judges every AUTHORED
`restrict` (the resolved behaviour of `cascadeRelationBehavior`: a
`lookup`, `tree` or `master_detail` that declares `deleteBehavior:
'restrict'`) held by a member that the cascade reaches only THROUGH the
record that restrict protects. Such a row refuses the delete with the
walk's existing `409 DELETE_RESTRICTED` envelope, built by one helper
(`deleteRestrictedError`, lifted unchanged out of the walk), so the two
refusals cannot drift.
"Reaches through" is read from the cascade edges the collection walk
already probes, now recorded on the set
(`CascadeDeleteSet.cascadeEdges`, `cascadeReachOf`). No new probe kind
and no new read of the registry: the step probes a restrict relation
only when its object has a member in the reach of the protected record.
## Why — where the ordering lives and why the cascade won
Re-checked on `origin/main` `c8583f80`. The card's suspected site holds:
the walk is `cascadeDeleteRelations` → `collectCascadeDeleteSet` (phase
1) → `walkReferencingRelations` (phase 2) in
`packages/objectql/src/engine.ts`. Two things let the cascade win:
1. **The #22305 exemption.** Phase 2 judged `restrict` only against rows
OUTSIDE the cascade set (`dependents.filter(…
!cascadeSetHas(cascadeSet.members, …))`). The child is in the parent's
set by its own master_detail, so its restrict lookup was never asked, in
either field order.
2. **Order inside the walk.** Each relation was judged as the field loop
reached it, so with the master_detail declared first the child was
already deleted when the restrict relation was probed.
Judging the restricts before the first write removes both. The #22305
rule itself stays for a row the cascade reaches through ANOTHER record
(two details of one account, one restricting the other): the account's
delete removes both.
## Tests
-
`packages/runtime/src/cascade-delete-restrict-same-parent-real-driver.integration.test.ts`
(new): real `ObjectQL` over a real `SqlDriver` (better-sqlite3 on disk),
through the protocol's `deleteData` (the method `DELETE
/api/v1/data/:object/:id` serves) and `deleteManyData` (the bulk door).
The card's shape in both field orders, the bulk door, a grandchild
holding the restrict; controls: the two fields at different parents, a
plain master_detail, the parent deleting once the child is gone. Row
counts are read out of the driver.
- `packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts`
(new section, the #22305 file and its rollback-capable driver): the same
shapes plus a grandchild restricting the member above it (refused from
the root and directly alike), each asserting no write reached the
driver; two controls. Every pre-existing #22305 pin in the file is
unchanged and green.
## Acceptance notes
- **Open question for the seat (not decided here).** The triage ruling
reads "a restrict on any path refuses the delete". The #22305 triage
ruling, pinned in `engine-cascade-delete-sibling-restrict.test.ts` ("an
AUTHORED restrict between two members does not refuse"), exempts an
authored restrict held by a SIBLING member: deleting an account removes
a quote and the contact the quote restricts, both details of the
account. This PR keeps that pin and refuses only the rows reached
through the record the restrict protects, which covers the card's shape,
the grandchild case and the reverse field order. Whether the sibling
case should refuse too is a reversal of the #22305 pin and is left to
the seat; it is a one-line change in the new step (drop the reach filter
for authored restricts). Measured producers: hotcrm (`afdf9a0`) declares
authored restricts only on `crm_event_attendee.crm_event` and
`crm_campaign_member.crm_campaign`, whose targets are never cascade
members together with those rows, and this repo ships one,
`sys_business_unit.parent_business_unit_id`; none of them meets either
rule's changed case.
- **The bulk door shares the walk.** `deleteManyData` deletes by id, row
by row, through `engine.delete`, and refuses the same row (pinned). The
engine's PREDICATE delete (`multi: true` with a non-id `where`) runs no
referential walk at all, by its documented contract (the
`cascadeDeleteRelations` docblock, and
`engine-cascade-delete-atomic.test.ts` "opens no transaction when … the
delete is by predicate"); it is unchanged here and not reached by this
card.
- **Required-lookup escalation unchanged.** A required lookup's
`set_null`, refused as `restrict` because it cannot be cleared, still
steps aside for a row the cascade deletes (#22305 "a member holding a
required lookup to the ROOT does not refuse the root", green).
- **Cost.** One extra probe per authored restrict relation per member,
only when the restricting object has a member in that member's cascade
reach; the walk already probed the same relation.
- No save-time refusal of the combination was added (that would be a
`packages/spec` card). No `ui/**`, no `packages/spec/**`.
- Overlapping open PR measured before opening: #26792 (engine split cut
16, the validate body) touches `engine.ts` in a different region.
## Verification
All readings on head `ba42fbd96c` (branch merged with `origin/main`
`0ea90c7a18`) unless named otherwise.
<details><summary>Red first on main, green after (real driver)</summary>
`pnpm --filter @objectstack/runtime exec vitest run --project local
src/cascade-delete-restrict-same-parent-real-driver.integration.test.ts`
- On `c8583f80` (main, test file only): `Tests 4 failed | 3 passed (7)`.
The card shape answered success in both field orders (`expected {
object: 'zz_widget_part', … } to be an instance of Error`), the bulk
door answered `success: true`, and the grandchild case answered no code.
The three controls were green.
- On `ba42fbd96c`: `Tests 7 passed (7)`.
</details>
<details><summary>Ablation (fix committed first, restored by blob
hash)</summary>
`node scripts/ablation-replace.mjs` replaced the
`refuseRestrictsInsideOwnCascade` call in `cascadeDeleteRelations` with
a no-op (anchor 1 → 0, blob `024eb5bf8ca8` → `08a2e3488091`) and ran
`engine-cascade-delete-sibling-restrict.test.ts`: `Tests 4 failed | 23
passed (27)`. The four that went red are the new non-control cases; both
new controls and every #22305 pin stayed green. Restored: blob after
restore = blob at HEAD = `024eb5bf8ca8`, `git diff HEAD` empty. The
objectql suite imports `./engine.js` from source, so no build was
involved.
</details>
<details><summary>Package suites, typecheck, lint</summary>
- `pnpm --filter @objectstack/objectql test`: `Test Files 475 passed
(475)`, `Tests 8849 passed (8849)` (on `40aace604d`, the same objectql
source as the head).
- `engine-cascade-delete-sibling-restrict.test.ts` 27/27 and the new
runtime file 7/7 again on `ba42fbd96c`.
- `pnpm --filter @objectstack/objectql typecheck && pnpm --filter
@objectstack/runtime typecheck`: exit 0 (both include
`check:test-typecheck`).
- `pnpm exec eslint --no-inline-config` over the three changed source
files: exit 0.
</details>
<details><summary>Derived gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives 63 families on this diff (unchanged
after the merge). All 63 ran on `ba42fbd96c`, every one exit 0; `--ran`
reconciles `63 run, 0 NOT-MEASURED, 0 UNRUN`. One read needed a rerun:
`check:watch-hint-literal` first exited 1 on `ENOENT …
packages/spec/tsup.config.bundled_….mjs`, a temp file a concurrent spec
build removed mid-scan; rerun after the build, exit 0.
`check-plugin-teardown-shape.mjs --self-test` needs the unshallowed
clone (exit 3 on the shallow one, exit 0 after `git fetch --unshallow`).
</details>
<details><summary>Dogfood battery (narrowing arm)</summary>
On `ba42fbd96c`, after `pnpm --workspace-concurrency=2 --filter
'@objectstack/dogfood^...' build` (exit 0), `pnpm --filter
@objectstack/dogfood test` under the verify lock: `VERDICT command-exit
0`, `Test Files 314 passed | 3 skipped (317)`, `Tests 2609 passed | 20
skipped (2629)`, no failure.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(No `packages/spec` change.)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes. (eslint over the changed
files; repo-wide lint is CI's.)
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed. (Not touched.)
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5mmLAr61euRoHc183L)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 031d97a commit 31a6ca7
4 files changed
Lines changed: 587 additions & 80 deletions
File tree
- .changeset
- packages
- objectql/src
- runtime/src
| 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 | |
|---|---|---|---|
| |||
485 | 485 | | |
486 | 486 | | |
487 | 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 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
0 commit comments