Skip to content

ObjectQL.delete's single-id cascade is not transactional — a refusal mid-cascade leaves earlier children deleted while the response says the delete failed #7413

Closed

Description

@baozhoutao

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:

  1. no transaction wraps the parent delete + its cascade;
  2. 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;
  3. the recursion re-enters delete(), which re-runs the full pipeline for each child —
    permission checks, beforeDelete hooks, 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) with deleteBehavior: 'cascade', 3 rows for the
    parent under test;
  • app_child_link — lookup(app_parent) with the default behaviour (set_null), 1 row,
    carrying an app beforeUpdate hook that throws Object.assign(new Error(...), { status: 409 })
    when the parent it points at is still alive.

Steps:

  1. 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);
  2. the engine cascades into app_child_snapshot first and deletes all 3 rows — committed;
  3. it then reaches app_child_link, issues the set_null update, and the app hook throws 409;
  4. 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:

  1. 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
  2. Refused up front — if the default driver cannot provide a transaction (the documented caveat
    on ObjectQL.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

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

  1. added
    pm:queueReady for the PM dispatch loop
    target:v17Release blocker for v17 — on the board until fixed, dropped as no longer valid, or accepted for GA
    on Aug 10, 2026
  2. claude commented on Aug 10, 2026

    @claude
    Contributor

    Triage: pm:queue + domain:engine-core + target:v17.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  3. claude commented on Aug 10, 2026

    @claude
    Contributor

    PM pre-dispatch assessment (domain:engine-core seat, #6019): implementation-shaped, ⛔ not a decision card. Dispatching when engine.ts frees (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:

    What remains for the dev is a measured argument, not a ruling: wrap the by-id delete + cascade in one this.transaction(...) — and choose require: true (refuse on transactionless drivers) vs default (degrade + warn) from consequences: census which drivers lack beginTransaction (in-tree and in the shipped set), and what a refusing delete() would break that works today. ⚠️ If that census shows require: true would make plain delete() start refusing on any real in-tree path, or the hook-inside-transaction semantics turn out to carry their own fork (side-effectful beforeDelete hooks 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

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 10, 2026
  5. claude commented on Aug 10, 2026

    @claude
    Contributor

    Claim: PM loop wave 8 (seat #6019)
    Session: session_01MwoubC3jL271FYt9rGXwxb
    Branch: claude/issue-7413-delete-cascade-transactional
    Worktree: objectstack-7413
    Domain: domain:engine-core

    Race re-read: body + triage + the PM assessment above re-read; no competing session ID. assignees measured EMPTY via REST immediately before assigning; pm:queue → pm:dispatched same write.

    engine.ts freed by PR #7469's merge (db3140233). Dispatching per the assessment: implementation-shaped, with the two stop-conditions standing.


    Generated by Claude Code

  6. claude commented on Aug 10, 2026

    @claude
    Contributor

    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:

    1. ✅ The require-vs-degrade census is decisive: beginTransaction is a required member of IDataDriver, and all five shipped drivers implement it — so degrade is unreachable on any conforming runtime, and require: true would only have refused the non-conforming population (~50 in-tree test doubles + embedder partials), turning every relational delete() 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.
    2. ✅ The hooks fork is measured as not a fork: the parent's own beforeDelete/afterDelete sit outside the wrap, and a cascaded child's afterDelete firing for a row a rollback restores is the established shape of every atomic path in this engine (runAtomicBatch et al.) — consistent, not newly wrong.
    3. ✅ The recursion joins, measured: one beginTransaction per multi-level cascade, pinned — ambient joining (ADR-0067 D2 / [spec] engine.transaction 契约收紧:opts.require fail-closed、跨驱动拒绝、owned-vs-joined 信号(#4619 的契约半边,维护者已批 P2) #5696), not nesting.
    4. ✅ Two subtleties beyond the brief, both real: driver options rebuilt inside the callback (else the parent's own driver.delete executes outside the transaction wrapping its cascade); and planCascadeAtomicity's 'split' verdict keeps a cross-datasource cascade on its old non-atomic answer instead of regressing it into CrossDatasourceTransactionWriteError (ambient 事务句柄跨数据源泄漏:凡在事务中执行的被审计写入,合规审计行全部静默丢失(#5226 的真实根因) #5351/[spec] engine.transaction 契约收紧:opts.require fail-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.
    5. ✅ The core pin is the card's repro inverted: mid-cascade restrict ⇒ zero children deleted; plus all-or-nothing across levels, set_null restored on rollback, hook counts on success and refusal, 513 lines of pins.
    6. ✅ Changeset with the fixed-group convention respected; ⛔ no packages/spec touch — no declaration needed.

    CI fully green. Auto-merge armed; issue closes on merge.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

pm:dispatchedDispatched to a dev agent by /pm-dispatchtarget:v17Release blocker for v17 — on the board until fixed, dropped as no longer valid, or accepted for GA

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions