Skip to content

Commit f672e0d

Browse files
refactor(objectql): move 36 hand-series private docblocks out of engine.ts with the codemod's third pass (slice 1 of 2) (#30341)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg1KEKUvnWZ8oYQ388cWv3dF)_ Refs #30053 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`scripts/codemod-split-engine.mjs` 加了第三遍 `--hand-docs`:手写系列(#25522 等)私有桩的 docblock 从 `engine.ts` 的类上搬到手写模块里它调用的那个函数上,替换原来的占位注释;公有 / protected 的 docblock 留在类上。本 PR 是两片中的第一片:8 个手写模块、36 块、929 行。生成物未手改。 - 为什么改:维护者在 #30012 上说「我觉得文件还是太长,还有进一步的拆分优化空间吗」「可以后续立卡处理」「合并之后再继续」。整卡约 4,100 行改动,超过 3,000 行线,所以席位定为拆两个串行 PR、不申请冻结窗口。 - 风险与回滚:只搬注释。撤销证明逐字节还原 base `engine.ts` 和 21 个手写模块;公有 `.d.ts` 相等;objectql 测试 base 550 文件 / 10,951 = head;回滚就是 revert。 - 先看:① 下方「留在类上的」表;② `engine.ts` 顶部被剪掉的 5 个 import 绑定(只被搬走的 docblock 的 `{@link}` 引用);③ 任一手写模块头部新增的一段说明。 - 两分钟验证:`pnpm install --frozen-lockfile` 后运行 `node scripts/codemod-split-engine.mjs --hand-docs --verify --base a26abbc`,应看到 `36 moved (929 lines) across 8 module(s)`、`13550 -> 12618 lines`,退出码 0;`--self-test` 105 例通过。 - 需要你做的:改动共 2,573 行(未超 3,000 行线),本 PR 保持草稿,按席位安排落地。第二片(其余 10 个模块,`Fixes #30053`)在本 PR 合并后从同一分支名接着开。 ## What changes - **`scripts/codemod-split-engine.mjs`: a third pass, `--hand-docs`.** The hand series moved its bodies out of `engine.ts` before the codemod existed and kept every docblock on the class; each hand-module function carries the placeholder `/** The body of \`ObjectQL#x\`; the method's docblock there is its contract. */`. The codemod's own rule moves a private method's docblock with its body and keeps a public or protected one on the class. This pass applies that rule to the hand series and touches nothing else. - **Which stubs.** A method whose body opens with a hand marker: `[#25522 cut N]` (83), `[#27495]` (2) or the untagged `The body lives in` of `serveReferenceNames` (1). 86 in all. - **The move.** A private stub's docblock leaves the class and replaces its function's placeholder, one level out (the hand series re-indented every body it moved, so the functions stand at column 0). The stub's marker gains `, its docblock with it`, the phrase the codemod's own stubs use. The module's header docblock gains one paragraph saying private docblocks moved here, because each header says the docblock stays on the class. - **The import prune.** `noUnusedLocals` counts a JSDoc `{@link X}` as a read, so five `engine.ts` import bindings went unused once their docblocks moved: `RelatedFieldBinding`, `collectReferenceTraversals`, `optionVisibilityReadsPermissions`, `readsPermissionPredicate` and `READABLE_IDS_MAX_CHAIN_DEPTH`. The pass prunes them with the first pass's `pruneImports`. The last one was a whole `import type` that existed only for those links, so it goes with the two `//` lines directly above it (a new `dropAttachedComments` option; the first pass keeps its default and its output is unchanged). - **Slices.** `--modules a.ts,b.ts` moves only the named modules' stubs, and a stub already moved is skipped, so the next slice runs on this one's output. - **The proof.** It undoes every move in the head and in the base (each docblock back on its stub, re-indented; the placeholder back; the marker phrase and the header note out) and requires the two to be equal, `engine.ts` and each hand module byte for byte. The import prune is recomputed from the head and applied to the base, and every pruned name must be linked by a moved docblock. A base no slice touched undoes to itself, so against `main` this is "the undo rebuilds the base"; against a base an earlier slice landed in, it proves only the new slice. `--hand-docs --verify --base <ref>` re-proves a tree on disk. - **`--self-test`**: 105 cases in 8 batteries (was 86 in 7). The new battery "hand-series docblocks (third pass)" (floor 19) covers the verdicts, the move, the marker, the header note, determinism, slices composing, the prune, and feeds the proof what it must refuse: one character edited in a moved docblock, one edited elsewhere in a hand module, a moved docblock dropped, a staying docblock dropped, a public docblock moved, a marker over a docblock still on the class, a missing header note, a dropped binding no moved docblock links, and an unknown `--modules` name. - **The generated slice.** `--hand-docs --modules <the 8 below> --write` on `main` `a26abbc6`, unedited (the branch merged `main` and its tree equals a fresh `--write` there). ### What moved in this slice (8 modules, 36 docblocks, 929 lines) | Hand module | Docblocks | Lines | Before → after | |:--|--:|--:|--:| | `engine-secret-internal-fields.ts` | 8 | 276 | 605 → 878 | | `engine-master-detail-related.ts` | 4 | 184 | 512 → 697 | | `engine-readable-ids-permission-formula.ts` | 7 | 171 | 362 → 531 | | `engine-delete-cascade.ts` | 5 | 169 | 498 → 667 | | `engine-execution-plumbing.ts` | 4 | 56 | 128 → 185 | | `engine-migration-flags.ts` | 3 | 32 | 295 → 329 | | `engine-datasource-routing.ts` | 3 | 25 | 185 → 212 | | `engine-package-registration.ts` | 2 | 16 | 212 → 231 | `packages/objectql/src/engine.ts`: 13,550 → 12,618 lines. ### What stays on the class | Stubs | Why | |:--|:--| | 20 public stubs (`validate`, `count`, `execute`, `resolveSecret`, `checkDriversHealth`, the compatibility API, …) | the docblock is the contract and stays on the facade | | `validationMessageContext` | its function already carries a docblock with more than the placeholder (an ADR-0029 D9.2a note); merging two docblocks is an edit, not a move | | `noteAdmittedValueShapeViolation` | it has no docblock | ### The second slice 10 modules, 28 docblocks, 858 lines: `engine-count-aggregate.ts`, `engine-driver-options.ts`, `engine-execute-transactions.ts`, `engine-file-reference-hydration.ts`, `engine-hook-dispatch.ts`, `engine-query-lowering.ts`, `engine-realtime-publish.ts`, `engine-reference-name-visible.ts`, `engine-referential-integrity.ts`, `engine-validation-messages.ts`. The plan on this head prints exactly that list (`--hand-docs` with no `--modules`), and its in-run proof against this slice passes. It opens after this PR merges and carries `Fixes #30053`. ## Verification All at head `b09479c5` unless stated. - **Proof.** `node scripts/codemod-split-engine.mjs --hand-docs --verify --base a26abbc`: exit 0. `36 moved (929 lines) across 8 module(s), 0 already moved at base; engine.ts 13550 -> 12618 lines; undoing every move rebuilds the base, undone the same way, for engine.ts and its 21 hand module(s) byte-for-byte, once the 5 import binding(s) only a moved docblock linked are pruned`. - **Determinism.** The head's script, run with `--write` in a separate worktree at `a26abbc6`, writes `engine.ts` and the 8 modules byte-identical to the committed head (`cmp`, all 9), output tree sha256 `7883ed99…8ea5`. - **`.d.ts`.** `packages/objectql/dist` built at base `a26abbc6` twice and at head twice. Base 1 = base 2 and head 1 = head 2, byte for byte. Base against head: `core` and `index` (`.d.ts` and `.d.mts`) are byte-identical; `util-HASH` has 193/193 statements and 334/334 private members, and its public surface (each class's private members removed with their docblocks) is **EQUAL**. The raw difference is the 929 lines of moved private docblocks (tsc emits a private member's docblock). Control: dropping one public docblock from the head's `.d.ts` is caught (`DIFFERS`). - **Ablation.** Three single-line ablations through `scripts/ablation-replace.mjs`, each restored to the HEAD blob: the per-module comparison off → "one edited character elsewhere in a hand module fails" goes red (this ablation first stayed green, which is why that case exists); the `engine.ts` comparison off → "one edited character in a moved docblock fails" and "a staying docblock dropped from the class fails" go red; the pruned-name link check off → "a binding the head dropped that no moved docblock links fails" goes red. - **Suites.** objectql `test`: base `a26abbc6` 550 files / 10,951 passed, head 550 / 10,951. objectql `typecheck` (tsc + check:test-typecheck): exit 0. - **Readers of these files by path.** core `second-object-read-exposure.pin.test.ts` and `row-serving-door-exposure.pin.test.ts` (7/7), types `driver-error-classification.callers.test.ts` (11/11), `check-facade-delegation-only` (OK, registry unchanged), `check:adr-anchors` (OK; `engine.ts` still names ADR-0057, ADR-0067 and ADR-0119), eslint on every changed file: exit 0. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 72 commands, each run after a full `turbo run build --filter='!@objectstack/docs'` (exit 0). All 72 exit 0; `--ran`: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN. ## Acceptance notes - `{@link}`s inside a moved docblock now sit in the hand module, where a name the class declares (`{@link getDriver}` and the like) resolves to the module's function of that name or to nothing. The text is unchanged by design: the pass moves bytes, it does not rewrite them. No gate reads those links. - A changeset (`patch`, `@objectstack/objectql`) is included, as #30052's was. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes on the changed files. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (the designated thread branch, as the Claim records; first of two slices) - [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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CYHBS7P5hkUQrNHkHZ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8589756 commit f672e0d

11 files changed

Lines changed: 1567 additions & 1010 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
refactor(objectql): the private docblocks of 36 hand-series stubs in `engine.ts` move into their hand modules, onto the functions that hold their bodies; no behaviour or public declaration changes
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- A new third pass of the `engine.ts` codemod, `--hand-docs`, moves a private hand-series stub's docblock onto the function its stub calls, as the first two passes already do for the bodies they move. A public or protected method keeps its docblock on the class.
11+
- This is the first of two slices: 8 hand modules here, the other 9 next. `engine.ts` goes from 13,550 to 12,618 lines.
12+
- Nothing to do on upgrade: the exported names, the public `.d.ts` surface and what every method does are the same.

‎packages/objectql/src/engine-datasource-routing.ts‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@
4545
* This module imports the engine only as a TYPE, so there is no module cycle
4646
* at run time, and nothing re-exports it, so the published surface does not
4747
* move.
48+
*
49+
* [#30053 codemod] A PRIVATE method's docblock has since moved here,
50+
* onto its function (`--hand-docs` of `scripts/codemod-split-engine.mjs`),
51+
* and the class keeps none for it; a public or protected method's docblock
52+
* stays on the class, the contract, and its function keeps the placeholder.
4853
*/
4954

5055
import type { IDataDriver } from '@objectstack/core';
@@ -68,7 +73,21 @@ export interface DatasourceRoutingHost {
6873
matchPattern: ObjectQL['matchPattern'];
6974
}
7075

71-
/** The body of `ObjectQL#getDriver`; the method's docblock there is its contract. */
76+
/**
77+
* Helper to get the target driver
78+
*
79+
* Resolution priority (first match wins):
80+
* 1. Object's explicit `datasource` field (if not 'default')
81+
* 2. DatasourceMapping rules (namespace/package/pattern matching)
82+
* 3. Lifecycle-class separation (ADR-0057 §3.6): telemetry/event/audit
83+
* objects route to the dedicated 'telemetry' datasource when registered
84+
* 4. Package's `defaultDatasource` from manifest
85+
* 5. Global default driver
86+
*
87+
* The order itself lives in {@link resolveDatasourceBinding} — this method
88+
* turns the name it decides on into a driver, and diagnoses the cases where
89+
* that driver is missing.
90+
*/
7291
export function getDriver(
7392
this: Pick<DatasourceRoutingHost, 'resolveDatasourceBinding' | 'drivers' | 'logger' | 'unavailableDatasources'>,
7493
objectName: string,
@@ -131,7 +150,12 @@ export function getDriver(
131150
throw new Error(`[ObjectQL] No driver available for object '${objectName}'`);
132151
}
133152

134-
/** The body of `ObjectQL#resolveDatasourceFromMapping`; the method's docblock there is its contract. */
153+
/**
154+
* Resolve datasource from mapping rules
155+
*
156+
* Rules are evaluated in order (or by priority if specified).
157+
* First matching rule wins.
158+
*/
135159
export function resolveDatasourceFromMapping(
136160
this: Pick<DatasourceRoutingHost, 'datasourceMapping' | 'matchPattern'>,
137161
objectName: string,
@@ -173,7 +197,10 @@ export function resolveDatasourceFromMapping(
173197
return null;
174198
}
175199

176-
/** The body of `ObjectQL#matchPattern`; the method's docblock there is its contract. */
200+
/**
201+
* Simple glob pattern matching
202+
* Supports * (any chars) and ? (single char)
203+
*/
177204
export function matchPattern(objectName: string, pattern: string): boolean {
178205
const regexPattern = pattern
179206
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars

‎packages/objectql/src/engine-delete-cascade.ts‎

Lines changed: 174 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@
6969
* other module this one imports, so the evaluation order of every module is
7070
* what it was. The published build bundles both files into one, where the
7171
* cycle does not exist.
72+
*
73+
* [#30053 codemod] A PRIVATE method's docblock has since moved here,
74+
* onto its function (`--hand-docs` of `scripts/codemod-split-engine.mjs`),
75+
* and the class keeps none for it; a public or protected method's docblock
76+
* stays on the class, the contract, and its function keeps the placeholder.
7277
*/
7378

7479
import type { ServiceObject } from '@objectstack/spec/data';
@@ -120,7 +125,65 @@ function cascadeReachOf(set: CascadeDeleteSet, object: string, id: unknown): Map
120125
return reach;
121126
}
122127

123-
/** The body of `ObjectQL#referenceProbeFilter`; the method's docblock there is its contract. */
128+
/**
129+
* [#9362] The dependents probe's `where`, spelled for the KIND of reference
130+
* field it is aimed at.
131+
*
132+
* A single-valued `lookup` / `master_detail` stores a scalar foreign key, and
133+
* bare equality is the right question about it. A MULTI-VALUED reference
134+
* field stores an ARRAY, and every SQL backend in this repo puts that array
135+
* in a JSON TEXT column. [#18408] Which of the two a field is, is
136+
* {@link declaredMultiValued} — the one predicate the storage side asks
137+
* (#17469) — ⛔ never a raw `field.multiple`: `master_detail` and `tree` are
138+
* outside `MULTI_CAPABLE_TYPES`, so the flag on one of those buys a JSON
139+
* column from nobody, and aiming `$contains` at the scalar column the driver
140+
* really built is the mirror image of the defect below.
141+
*
142+
* Aiming bare equality at a multi-valued column compares
143+
* the whole serialization (`["a","b"]`) against one id, which can never hold;
144+
* `driver-sql` refuses the spelling outright (`INVALID_FILTER` / 400, #7398),
145+
* and that refusal is correct and stays. Until this method existed the probe
146+
* built bare equality for both kinds, so every object pointed at by any
147+
* registered `multiple: true` lookup had its delete refused with a 400 — on
148+
* the stock showcase, `showcase_account`, with an EMPTY dependent table:
149+
* the fault is schema-driven, not data-driven, because the probe runs per
150+
* DECLARED relation.
151+
*
152+
* The multi-value spelling is `$contains`, which is what the refusal itself
153+
* prescribes and the one membership spelling every driver here answers. On a
154+
* column the driver holds declared multi-valued it asks MEMBERSHIP, as
155+
* `FILTER_OPERATORS`' `$contains` docblock (`@objectstack/spec`) declares for
156+
* a JSON-stored column: `driver-sql` (and `driver-sqlite-wasm` and the local
157+
* `driver-turso`, which extend it) compiles a per-dialect membership
158+
* construct, `driver-memory` a mingo `$elemMatch`, and `driver-mongodb` the
159+
* same `$elemMatch`. No public filter surface is widened: `$contains` is
160+
* already declared, and this is the only construction site that changes.
161+
*
162+
* Membership answers EXACTLY on a well-formed slot, but a backend that does
163+
* not hold the declaration (a table it was never told about) answers the
164+
* SUBSTRING reading instead, which is a SUPERSET: with ids `acc_1` and
165+
* `acc_10`, a row holding `acc_10` also matches a probe for `acc_1`. That is
166+
* why the caller still narrows the rows exactly through
167+
* {@link ObjectQL.storedReferenceIncludes} — over-matching here would make
168+
* `cascade` DELETE and `set_null` clear rows that never referenced this
169+
* record, which is worse than the 400 being fixed. The pushdown's only job is
170+
* to keep the probe from reading the whole table.
171+
*
172+
* ⚠️ Membership is array-only on every backend that answers it, so a slot
173+
* holding an off-shape bare scalar (the write door never stores one, it wraps
174+
* a scalar; out-of-band data can) is not matched by the pushdown, and the
175+
* scalar arm of {@link ObjectQL.storedReferenceIncludes} never sees that row.
176+
*
177+
* The `$or` limb covers the other direction — a FALSE NEGATIVE, which on an
178+
* integrity guard is the fail-OPEN that #8895 ruled out. An id needing JSON
179+
* escaping (a quote, a backslash) appears in the serialized text in its
180+
* ESCAPED form, so a backend answering the substring reading over that text
181+
* would miss the row for the raw id; a membership test, and a document or
182+
* in-memory backend, compares the element itself and needs the RAW form. Both
183+
* are asked whenever they differ, and the exact narrowing discards whatever
184+
* the extra limb over-matched. Identical for an ordinary id, which is every
185+
* id this engine generates.
186+
*/
124187
export function referenceProbeFilter(
125188
fieldName: string,
126189
fdef: { type?: unknown; multiple?: unknown },
@@ -141,7 +204,48 @@ export function referenceProbeFilter(
141204
};
142205
}
143206

144-
/** The body of `ObjectQL#recordReferenceCheckElevation`; the method's docblock there is its contract. */
207+
/**
208+
* [#12166, ruling constraint 3] File the reference-check elevation, BOTH
209+
* halves: `triggeredBy` = the operator who asked for the delete,
210+
* `executedAs: 'system'` = the identity the check actually ran under.
211+
*
212+
* ## Why a record exists at all
213+
*
214+
* The elevation is the whole of what this card changed, and an elevation
215+
* nobody can see afterwards is the thing a security reviewer has no way to
216+
* audit. The ruling names the Salesforce/Dataverse ledger shape for exactly
217+
* this reason: a platform-executed integrity action is attributable to the
218+
* human who triggered it, not laundered into "system did something".
219+
*
220+
* ## Why the LOGGER and not a `sys_audit_log` row
221+
*
222+
* Stated plainly, because a narrow audit edge has to be visible to be honest
223+
* (`read-audit.ts` takes the same posture about its own narrowing):
224+
*
225+
* The elevated operation is a READ, and `plugin-audit`'s read writer declares
226+
* — and pins — that a system-elevated read produces NO row, deliberately:
227+
* those are the platform reading for its own bookkeeping, and recording them
228+
* would bury the human views that ledger exists to make findable. Minting a
229+
* row here would either contradict that declared boundary or require the
230+
* engine to reach into a plugin it does not depend on. The engine's logger is
231+
* the channel it owns, and it is where the reporting deployment read the
232+
* defect in the first place — the issue's evidence is a server log line.
233+
*
234+
* So: this is a LOG record, at `info`, and the honest limit of it is that a
235+
* deployment which does not retain engine logs retains no trace. A durable
236+
* `sys_audit_log` row for the elevation is a follow-up that belongs to
237+
* plugin-audit, which owns that row shape.
238+
*
239+
* `debug` was not an option: a record that is off by default in production is
240+
* not an audit record.
241+
*
242+
* ⛔ The record names the referenced OBJECT and the relation FIELD — declared
243+
* metadata — and never a row id, a field value, or a count. Constraint 2
244+
* governs what the CALLER is told; this is the server-side ledger and could
245+
* defensibly say more, but saying more here is how a count reaches a support
246+
* transcript, and the two surfaces are kept on one rule so neither has to be
247+
* re-audited alone.
248+
*/
145249
export function recordReferenceCheckElevation(
146250
this: Pick<DeleteCascadeHost, 'logger'>,
147251
object: string,
@@ -247,7 +351,35 @@ export function collectCascadeDeleteObjects(
247351
return named;
248352
}
249353

250-
/** The body of `ObjectQL#collectCascadeDeleteSet`; the method's docblock there is its contract. */
354+
/**
355+
* [#22305] Phase 1 of {@link cascadeDeleteRelations}: every record a by-id
356+
* delete of `object`/`id` removes, the root included, collected READ-ONLY.
357+
*
358+
* Breadth-first across every relation whose resolved behaviour is
359+
* `cascade`, read through the walk's own two readings — which relations
360+
* point at an object ({@link cascadeRelationBehavior}) and which rows
361+
* reference a record ({@link probeReferencingRows}: system identity, the
362+
* same filter, the same missing-table and multi-value handling) — so the
363+
* two phases cannot disagree about which rows a cascade reaches. A record
364+
* already in the set is not expanded again, so a cycle in the data (a
365+
* self-reference, two objects cascading into each other) terminates.
366+
*
367+
* The cost is one extra probe per CASCADING relation per member, since the
368+
* walk probes those relations again as it deletes. `restrict` and
369+
* `set_null` relations are not read here. Every probe here is an elevated
370+
* read, so it files its #12166 record before it runs, through the
371+
* once-per-record ledger the walk shares ({@link fileReferenceCheckElevation}).
372+
*
373+
* [#24964] …except for {@link inspectCascadeDelete}, which passes
374+
* `fileElevations: false`: that record says "read as system FOR THE DELETE
375+
* of" the record, and an inspection deletes nothing, so filing it there would
376+
* put a delete that never happened in the log. The reads are the same.
377+
*
378+
* [#27004] From a root SET, level by level: the records found at one depth
379+
* are probed together, per object and per relation, through
380+
* {@link probeReferencingRowsOfSet}. The set and its edges are the ones the
381+
* record-by-record walk collected.
382+
*/
251383
export async function collectCascadeDeleteSet(
252384
this: Pick<DeleteCascadeHost, 'cascadeRelationBehavior' | 'fileReferenceCheckElevation' | 'probeReferencingRowsOfSet'>,
253385
object: string,
@@ -312,7 +444,38 @@ export async function collectCascadeDeleteSet(
312444
return set;
313445
}
314446

315-
/** The body of `ObjectQL#refuseRestrictsInsideOwnCascade`; the method's docblock there is its contract. */
447+
/**
448+
* [#26718] Before the cascade writes anything: refuse the delete when an
449+
* AUTHORED `restrict` is held by a row the cascade would remove only because
450+
* the record that restrict protects is removed.
451+
*
452+
* The deleting walk judges a `restrict` against rows OUTSIDE the cascade set
453+
* alone (#22305), and a row inside it was exempt whatever put it there. That
454+
* exemption is right for a row the cascade reaches through ANOTHER record (a
455+
* contract and the contact it restricts, both details of the account being
456+
* deleted: the account's delete removes both, and neither is left dangling).
457+
* It was wrong for a row reached THROUGH the very record it protects: a child
458+
* holding a `master_detail` and a `restrict` lookup to the same parent is in
459+
* that parent's set by its own master_detail, so the restrict was never
460+
* asked and the cascade deleted the child — the record the author declared
461+
* undeletable. A grandchild under that parent holding the restrict is the
462+
* same case one level down.
463+
*
464+
* So for every member, every relation whose RESOLVED behaviour is `restrict`
465+
* ({@link cascadeRelationBehavior}, an authored `restrict` on a lookup,
466+
* tree or master_detail) is probed here when its object has a member in the
467+
* reach of that member ({@link cascadeReachOf}); a referencing row in the
468+
* reach refuses with the walk's own `DELETE_RESTRICTED`
469+
* ({@link deleteRestrictedError}). Every restrict is judged before the
470+
* first cascade, so the outcome no longer depends on the order the fields or
471+
* objects are declared in, and nothing has been written when it refuses.
472+
*
473+
* ⛔ The required-lookup escalation (`set_null` on a required field read as
474+
* `restrict`) is not judged here: it restricts because a required reference
475+
* cannot be CLEARED, and a row the cascade deletes is never cleared. It keeps
476+
* the #22305 rule. Neither does this replace the walk's judgement of rows
477+
* outside the set, which is unchanged.
478+
*/
316479
export async function refuseRestrictsInsideOwnCascade(
317480
this: Pick<DeleteCascadeHost, 'cascadeRelationBehavior' | 'fileReferenceCheckElevation' | 'probeReferencingRowsOfSet' | 'deleteRestrictedError' | 'referenceProbeFilter'>,
318481
cascadeSet: CascadeDeleteSet,
@@ -396,7 +559,13 @@ export async function refuseRestrictsInsideOwnCascade(
396559
}
397560
}
398561

399-
/** The body of `ObjectQL#deleteRestrictedError`; the method's docblock there is its contract. */
562+
/**
563+
* The `DELETE_RESTRICTED` refusal of deleting `object`/`id` because
564+
* `dependents` of `childName` reference it via `fieldName`. One builder for
565+
* the deleting walk ({@link walkReferencingRelations}) and the judgement
566+
* before it ({@link refuseRestrictsInsideOwnCascade}), so the two answer one
567+
* envelope.
568+
*/
400569
export async function deleteRestrictedError(
401570
this: Pick<DeleteCascadeHost, 'validationMessageContext' | 'dependentCountIsDisclosable' | 'objectDisplayLabel'>,
402571
args: {

0 commit comments

Comments
 (0)