Repository navigation
Description
Summary
ObjectQL.delete() runs its cascade outside any transaction. cascadeDeleteRelations is a
plain for loop that recursively calls this.delete() / this.update(), and every one of those
calls commits on its own. So when the cascade is refused partway through — by a permission check,
by the engine's own restrict branch, or by an app beforeDelete/beforeUpdate hook on a later
child — the rows already deleted in earlier iterations stay deleted, while the request returns
a failure status and the parent row is left untouched.
The caller sees 409 (or 403) and reasonably concludes "nothing happened". In fact an arbitrary
prefix of the child set is gone, with no way to reconstruct it: a partial delete has no natural undo.
This is the same class of defect as #4620 (deleteManyData was fake-atomic), whose changeset
many-data-atomic-real-or-refused.md set the principle explicitly:
atomic— honoured for real, or refused. … a partial delete has no natural undo — a client
cannot reconstruct the rows from its own request.
ObjectQL.delete's single-id cascade path never got that treatment.
Where
packages/objectql (observed in @objectstack/objectql@17.0.0-rc.5, dist/core.js).
delete() — no transaction() / beginTransaction anywhere in the path:
async delete(object, options) {
...
await this.executeWithMiddleware(opCtx, async () => {
...
await this.triggerHooks("beforeDelete", hookContext);
...
if (hookContext.input.id) {
await this.cascadeDeleteRelations(object, hookContext.input.id, opCtx.context);
result = await driver.delete(object, hookContext.input.id, hookContext.input.options);
}
...cascadeDeleteRelations() — a loop of independent, already-committed writes:
async cascadeDeleteRelations(object, id, context, depth = 0) {
...
for (const child of objects) {
for (const [fieldName, fdef] of Object.entries(fields)) {
...
let behavior = fdef.type === "master_detail"
? (fdef.deleteBehavior === "restrict" ? "restrict" : "cascade")
: (fdef.deleteBehavior || "set_null");
if (behavior === "set_null" && fdef.required === true) behavior = "restrict";
...
if (behavior === "restrict") { /* throws DELETE_RESTRICTED 409 */ }
for (const dep of dependents) {
if (behavior === "cascade") {
await this.delete(childName, { where: { id: depId }, context }); // ← commits immediately
} else {
await this.update(childName, { id: depId, [fieldName]: null }, { context: referentialCtx });
}
}
}
}
}Three properties combine into the defect:
- no transaction wraps the parent delete + its cascade;
- children are visited in
getAllObjects()order, so which children are already gone when the
failure lands is essentially arbitrary from the caller's point of view; - the recursion re-enters
delete(), which re-runs the full pipeline for each child —
permission checks,beforeDeletehooks, nested cascade — i.e. plenty of places to fail late.
Reproduction (observed)
Setup — a downstream business app, all names generalised:
app_parent— a record with several cascade children;app_child_snapshot—lookup(app_parent)withdeleteBehavior: 'cascade', 3 rows for the
parent under test;app_child_link—lookup(app_parent)with the default behaviour (set_null), 1 row,
carrying an appbeforeUpdatehook that throwsObject.assign(new Error(...), { status: 409 })
when the parent it points at is still alive.
Steps:
DELETE /api/v1/data/app_parent/<id>as a principal with full delete rights (reproduced with a
platform-administrator account, so no app-level authorisation is in play);- the engine cascades into
app_child_snapshotfirst and deletes all 3 rows — committed; - it then reaches
app_child_link, issues theset_nullupdate, and the app hook throws 409; - the error propagates out of
delete().
Observed, stable across repeated runs (each measured with a direct SQL count against the store,
not via the API):
| assertion | before | after |
|---|---|---|
| HTTP status | — | 409 (failure) |
app_parent row exists |
yes | yes — untouched |
app_child_snapshot rows |
3 | 0 — silently destroyed |
app_child_link rows |
1 | 1 — untouched |
So the request reports failure and leaves the parent in place, while 3 child records that were
never individually addressed are permanently gone.
The source of the mid-cascade refusal is incidental — the same shape is reachable with no app
code at all: give the parent one cascade child and one required lookup child (the latter is
coerced to restrict by the line quoted above). The engine deletes the cascade child's rows, then
its own restrict branch throws DELETE_RESTRICTED 409 for the required one. Nothing outside
packages/objectql is needed to reach the inconsistent state. (Stated from the source; the table
above is the variant I actually measured.)
Expected
Either of the two, matching the principle #4620 already established for the batch path:
- Atomic for real — wrap the parent delete and its whole cascade in
engine.transaction(), so
a refusal anywhere rolls the entire thing back and "409" honestly means "nothing changed"; or - Refused up front — if the default driver cannot provide a transaction (the documented caveat
onObjectQL.transaction()in ADR-0119), resolve the full cascade set and pre-flight every
refusal source before the first destructive write, so the operation either fails having
changed nothing, or proceeds knowing it can finish.
Silently best-effort is the one option that should not remain, because the failure is invisible:
the response says the delete did not happen.
Prior art / why this is not a duplicate
- 属主守卫与级联 set_null 冲突:非特权删除 sys_user 时 owner_id 级联置空被 #3004 守卫拦截(级联中途失败) #3023 (closed) — "cascade
set_nullblocked mid-way by the owner guard". Its body already
states, in passing, that there is no transaction wrapping the cascade and that it therefore fails
mid-way leaving partially cleared/deleted children. But its ask was narrow (exempt
engine-internal referential writes from the owner-transfer guard) and was fixed that way — the
__referentialFieldClear: truemarker now incascadeDeleteRelationsis that fix. The atomicity
gap itself was never filed. - fix(metadata-protocol): deleteManyData has the same fake-atomic as batchData, updateManyData ignores atomic entirely #4620 (closed) — same principle, but the batch path (
deleteManyData/updateManyData
inmetadata-protocol). Different code; the single-id cascade inobjectqlwas out of scope. - DELETE_RESTRICTED (409) message is shown verbatim to end users: English-only, leaks API names, and contains developer-facing advice #7307 (open) — same function, but explicitly and only about the user-facing copy of the
DELETE_RESTRICTEDmessage ("The error transport itself is fine … purely about the user-facing
copy"). Orthogonal to this report.
Searched before filing: cascade transaction, cascade delete atomic, cascade rollback,
delete rollback transaction, cascadeDeleteRelations, cascade delete, deleteBehavior
(full-text), plus cascade / atomic / transaction / rollback title-scoped, plus a full scan
of the ~1000 filenames in .changeset/. No existing issue covers single-id cascade atomicity.
Impact
Any app whose objects use deleteBehavior: 'cascade' and whose children can refuse a delete —
which includes the ordinary case of per-object permissions differing between parent and child,
since the recursive this.delete() re-authorises every child independently. A failed delete
becomes silent data loss, and it is invisible in exactly the situation where the operator has been
told the operation did not go through.
Activity
claude commentedon Aug 10, 2026
Triage:
pm:queue+domain:engine-core+target:v17.origin/main@b948a41:ObjectQL.delete's by-id branch callscascadeDeleteRelationsand thendriver.deletewith no transaction wrap (packages/objectql/src/engine.ts:8584-8587), andcascadeDeleteRelations(:8204) recursively re-entersthis.delete()— each child commits independently, so a mid-cascade refusal (restrict / permission / later child hook) strands an arbitrary prefix of children deleted while the caller receives a failure status. The governing principle is already ruled: fix(metadata-protocol): deleteManyData has the same fake-atomic as batchData, updateManyData ignores atomic entirely #4620's changeset (many-data-atomic-real-or-refused.md) — atomic honoured for real, or refused. This path never got that treatment; execution of an established invariant, not new design.packages/objectql⇒domain:engine-core.deleteManyData, closed). Clean.target:v17rationale (binary test): class ① — silent partial data loss on the published surface, reachable on an ordinary delete whenever parent/child permissions differ or a later child hook refuses; measured in a downstream business app on rc.5; a partial delete has no natural undo. Veto window open on the grading.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
claude commentedon Aug 10, 2026
PM pre-dispatch assessment (
domain:engine-coreseat, #6019): implementation-shaped, ⛔ not a decision card. Dispatching whenengine.tsfrees (PR #7469 is ahead of it in the serial queue).The design fork this card looks like it carries — "what happens on a driver without transactions" — is already ruled and mechanized in the tree, so it does not go to the decision box:
engine.transaction(callback, opts)exists with both semantics:opts.require === truefails closed (TransactionUnsupportedError, refused before the callback writes anything — [spec] engine.transaction 契约收紧:opts.requirefail-closed、跨驱动拒绝、owned-vs-joined 信号(#4619 的契约半边,维护者已批 P2) #5696 point 1, which its own comment says "generalizesbatchData's atomic gate, ADR-0119 D4"), and withoutrequireit degrades with a once-per-engine-per-driver warning (fix(objectql): engine.transaction hardening — silent degrade, default-driver-only, and no owned-vs-joined signal (ADR-0118 D1 caveats) #4619).transaction()joins the outer one ([spec] engine.transaction 契约收紧:opts.requirefail-closed、跨驱动拒绝、owned-vs-joined 信号(#4619 的契约半边,维护者已批 P2) #5696), which matters here because the cascade re-entersdelete()recursively.What remains for the dev is a measured argument, not a ruling: wrap the by-id delete + cascade in one⚠️ If that census shows
this.transaction(...)— and chooserequire: true(refuse on transactionless drivers) vs default (degrade + warn) from consequences: census which drivers lackbeginTransaction(in-tree and in the shipped set), and what a refusingdelete()would break that works today.require: truewould make plaindelete()start refusing on any real in-tree path, or the hook-inside-transaction semantics turn out to carry their own fork (side-effectfulbeforeDeletehooks now running inside a rollback-able scope) — stop and report; that residue is decision-shaped even though the wrapper is not.Generated by Claude Code
claude commentedon Aug 10, 2026
Race re-read: body + triage + the PM assessment above re-read; no competing session ID.
assigneesmeasured EMPTY via REST immediately before assigning;pm:queue→pm:dispatchedsame write.engine.tsfreed by PR #7469's merge (db3140233). Dispatching per the assessment: implementation-shaped, with the two stop-conditions standing.Generated by Claude Code
claude commentedon Aug 10, 2026
ACCEPT — PR #7476. Ready + queued.
Verified by reading the body and diff. Both decision-shaped stop-conditions from the pre-dispatch assessment were measured shut, which is the best possible outcome:
beginTransactionis a required member ofIDataDriver, and all five shipped drivers implement it — so degrade is unreachable on any conforming runtime, andrequire: truewould only have refused the non-conforming population (~50 in-tree test doubles + embedder partials), turning every relationaldelete()on them into a throw for an atomicity nobody opted into. Degrade chosen; ⛔ the losing option's cost is stated and pinned (on a non-rollback driver the old partial-cascade window remains, reported once per driver) so later edits must argue, not drift.beforeDelete/afterDeletesit outside the wrap, and a cascaded child'safterDeletefiring for a row a rollback restores is the established shape of every atomic path in this engine (runAtomicBatchet al.) — consistent, not newly wrong.beginTransactionper multi-level cascade, pinned — ambient joining (ADR-0067 D2 / [spec] engine.transaction 契约收紧:opts.requirefail-closed、跨驱动拒绝、owned-vs-joined 信号(#4619 的契约半边,维护者已批 P2) #5696), not nesting.driver.deleteexecutes outside the transaction wrapping its cascade); andplanCascadeAtomicity's'split'verdict keeps a cross-datasource cascade on its old non-atomic answer instead of regressing it intoCrossDatasourceTransactionWriteError(ambient 事务句柄跨数据源泄漏:凡在事务中执行的被审计写入,合规审计行全部静默丢失(#5226 的真实根因) #5351/[spec] engine.transaction 契约收紧:opts.requirefail-closed、跨驱动拒绝、owned-vs-joined 信号(#4619 的契约半边,维护者已批 P2) #5696 point 2) — a hard refusal would have been strictly worse than the behaviour it has always had.'none'keeps relation-less deletes byte-identical.restrict⇒ zero children deleted; plus all-or-nothing across levels,set_nullrestored on rollback, hook counts on success and refusal, 513 lines of pins.packages/spectouch — no declaration needed.CI fully green. Auto-merge armed; issue closes on merge.
Generated by Claude Code
fix(objectql): ObjectQL.delete's by-id cascade is one unit of work (o…
docs(spec,objectql): declare after* hooks fire inside the unit of work (