Skip to content

Commit db2ea82

Browse files
os-zhuangclaude
andauthored
fix(objectql): bind hookContext.previous on a single-record delete (#5272) (#5283)
`HookContext.previous` is documented "for update/delete", and `update()` has bound it all along — `delete()` never did. `previous` was `undefined` in both `beforeDelete` and `afterDelete` for every single-record delete, so a legal delete-side condition (`previous.status == 'done'`) was unevaluable and, since #4775, rejected the whole operation — through the generic branch, which reads as an author typo when the engine was simply not binding the key. #5038 inverted the asymmetry: a predicate bulk delete already binds each doomed row's pre-image on its per-row `afterDelete`, so the single-record path was strictly worse than the bulk one — the opposite of the #4800/#4862 ruling that single and bulk mean the same thing. `delete()` now reads the doomed row once, before `beforeDelete` fires, and binds it for both phases. The gate is demand-driven like `update()`'s: a delete-side hook in either phase, or a roll-up summary aggregating this object. The roll-up path's own later pre-image fetch is folded into that same read, so an object with both pays one read, not two — and it is now the raw driver read `update()` already hands `recomputeSummaries`. A missing row leaves `previous` unbound rather than fabricating `{}`/`null`, and a `beforeDelete` hook that repoints or clears the target id re-reads or drops the binding so a stale pre-image never rides into `afterDelete`. The pin that hid this — `hook-condition-previous-scope.test.ts`'s "a delete-shaped context evaluates `previous` against the pre-image" — built `previous` by hand and asserted the wrapper read it, greenlighting behaviour the engine never produced. Replaced with end-to-end cases that drive a real `engine.delete()`: both phases receive the stored pre-image, a `previous.*` condition evaluates instead of rejecting, declared-field materialisation and no-leak hold on the delete side, the read happens exactly once for the two phases, an object with no delete-side hook pays no read at all, and a missing row leaves `previous` unbound. Fixes #5272 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f0e7cb commit db2ea82

3 files changed

Lines changed: 336 additions & 26 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a single-record `delete()` binds `hookContext.previous` — the pre-image the contract has always promised (#5272)
6+
7+
`HookContext.previous` is documented in the spec as *"the state of the record
8+
BEFORE the operation (**for update/delete**)"*, and `update()` has bound it all
9+
along. `delete()` never did. `previous` was `undefined` in **both**
10+
`beforeDelete` and `afterDelete`, for every single-record delete, on every
11+
object.
12+
13+
That is not a cosmetic gap. Since #4775 a condition that cannot be evaluated
14+
**fails the operation**, so a legal, contract-shaped delete-side hook:
15+
16+
```ts
17+
{ events: ['afterDelete'], condition: "previous.status == 'done'" }
18+
```
19+
20+
rejected *every* single-record delete of that object — and reported it through
21+
the generic branch (`Unknown variable: previous`), which reads like the author
22+
misspelled a key. The key was fine; the engine never bound it. Same shape as
23+
#5037: a platform gap surfacing as the author's mistake.
24+
25+
**Why now.** #5038 made a predicate bulk delete dispatch `afterDelete` once per
26+
matched row, each carrying that row's own pre-image. The single-record path
27+
still bound nothing, so it became strictly *worse* than the bulk path — the
28+
exact inversion the #4800/#4862 ruling ("an author writes the hook once; single
29+
and bulk mean the same thing") exists to prevent.
30+
31+
**What changed.** `delete()` now takes the doomed row's pre-image once, before
32+
`beforeDelete` fires, and binds it to `hookContext.previous` for both phases —
33+
so hook `condition`s, record-change flow triggers and delete-audit handlers all
34+
see the deleted row. The read is demand-driven, exactly like `update()`'s: it
35+
happens only when the object has a delete-side hook (either phase) or a roll-up
36+
summary aggregating it. An object with neither pays nothing, and an object with
37+
both phases pays **one** read, not two — the roll-up path's own separate
38+
pre-image fetch has been folded into this one, and is now the same raw driver
39+
read `update()` already feeds the summary recompute.
40+
41+
Nothing is fabricated: if the row is not there, `previous` stays **unbound**
42+
rather than becoming `{}`/`null`, so a condition reading it still faults loudly
43+
instead of answering for a record nobody read (#4649/#4775). The batch dispatch
44+
of a predicate delete still carries no `previous` — it stands for N rows — and
45+
its per-row `afterDelete` contexts are unchanged.
46+
47+
Upgrade impact: a delete-side hook whose `condition` reads `previous` starts
48+
evaluating instead of rejecting the write, and delete-side handlers start
49+
receiving `ctx.previous`. If you worked around the gap by testing
50+
`ctx.previous == null` to detect "this is a delete", that test now answers
51+
differently — read `ctx.event` instead.

packages/objectql/src/engine.ts

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5532,7 +5532,88 @@ export class ObjectQL implements IObjectQLEngine {
55325532
transaction: opCtx.context?.transaction,
55335533
ql: this
55345534
};
5535+
5536+
// [#5272] The pre-image of the row this delete is about to remove.
5537+
//
5538+
// `HookContext.previous` is documented — in the spec, since it was
5539+
// written — as "the state of the record BEFORE the operation (for
5540+
// update/delete)", and `update()` has bound it all along. `delete()`
5541+
// never did: `previous` was `undefined` in `beforeDelete` AND in
5542+
// `afterDelete`, so a legal, contract-shaped delete-side transition
5543+
// condition (`previous.status == "done"`) was unevaluable — and since
5544+
// #4775 unevaluable REJECTS the operation. Worse, it was reported
5545+
// through the generic branch, which reads as "you misspelled a key"
5546+
// when the key was fine and the engine simply never bound it (#5037's
5547+
// shape, one path over).
5548+
//
5549+
// #5038 made the asymmetry visible from the other side: a predicate
5550+
// bulk delete already binds each doomed row's own pre-image on its
5551+
// per-row `afterDelete`, so the SINGLE-record path was strictly worse
5552+
// than the bulk one — the exact inversion #4800/#4862 ruled against.
5553+
//
5554+
// Demand-driven, like `update()`'s `priorRecord`: read only when
5555+
// something on this object actually consumes it —
5556+
// * a delete-side hook, EITHER phase (its `condition` may read
5557+
// `previous`; its handler — plugin-audit, the record-change
5558+
// trigger — reads `ctx.previous` directly);
5559+
// * a roll-up summary aggregating this object, which needs the
5560+
// doomed row's FK value to find the parent to recompute.
5561+
// Those two used to be separate reads at separate times (the summary
5562+
// one fetched only after `beforeDelete` had run); they are ONE read
5563+
// now — and a RAW driver read, which is exactly what `update()`
5564+
// already hands `recomputeSummaries` as its `previous` argument, so
5565+
// the two write paths now agree on what a pre-image is.
5566+
//
5567+
// `needsPriorRecord(schema)` is deliberately NOT part of this gate
5568+
// even though `update()`'s twin carries it: object validation rules
5569+
// are evaluated on insert/update only — `delete()` evaluates none —
5570+
// so including it would buy a read with no reader.
5571+
//
5572+
// Read BEFORE `beforeDelete` fires. A delete's `before` phase is the
5573+
// one that has nothing else to look at (its `input` carries an id and
5574+
// no data), and the pre-image has to be taken before the row is gone
5575+
// either way, so a single read serves both phases.
5576+
const deleteSchema = this._registry.getObject(object);
5577+
const wantsPreImage =
5578+
this.hasHooksFor('beforeDelete', object) ||
5579+
this.hasHooksFor('afterDelete', object) ||
5580+
this.getSummaryDescriptors(object).length > 0;
5581+
// `buildDriverOptions` is what carries the open transaction and the
5582+
// tenant scope onto a raw driver read. Skipping it here would read
5583+
// outside this write's transaction and across the tenant boundary —
5584+
// `update()`'s prior read passes the same bag for the same reason.
5585+
const readPreImage = async (targetId: unknown): Promise<Record<string, unknown> | null> => {
5586+
const preAst: QueryAST = { object, where: { id: targetId }, limit: 1 };
5587+
const preOpts = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any);
5588+
return (await driver.findOne(object, preAst, preOpts)) as Record<string, unknown> | null;
5589+
};
5590+
const bindPreImage = (row: Record<string, unknown> | null): void => {
5591+
// Never fabricate: a row that is not there leaves `previous` UNBOUND
5592+
// rather than `{}`/`null`, so a condition reading it faults loudly
5593+
// instead of answering for a record nobody read (#4649/#4775).
5594+
hookContext.previous = row ? (coerceBooleanFields(deleteSchema as any, row as any) as any) : undefined;
5595+
};
5596+
let priorRecord: Record<string, unknown> | null = null;
5597+
if (id && wantsPreImage) {
5598+
priorRecord = await readPreImage(id);
5599+
bindPreImage(priorRecord);
5600+
}
5601+
55355602
await this.triggerHooks('beforeDelete', hookContext);
5603+
5604+
// A `beforeDelete` hook may repoint the target id, or clear it (which
5605+
// #4550's re-asked dispatch verdict below already accounts for). The
5606+
// pre-image bound above describes the OLD id, so it must not ride into
5607+
// `afterDelete` — or into the summary recompute — as though it
5608+
// described the new target. A cleared id falls through to the predicate
5609+
// branch, whose batch-scoped dispatch must carry no single row's
5610+
// pre-image at all (`hook-wrappers` diagnoses that dispatch by the
5611+
// absence of both).
5612+
if (wantsPreImage && hookContext.input.id !== id) {
5613+
priorRecord = hookContext.input.id ? await readPreImage(hookContext.input.id) : null;
5614+
bindPreImage(priorRecord);
5615+
}
5616+
55365617
hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any);
55375618

55385619
try {
@@ -5545,15 +5626,6 @@ export class ObjectQL implements IObjectQLEngine {
55455626
// `record-after-delete` flow must see each deleted row rather than
55465627
// one context that names none of them.
55475628
let bulkPerRowRows: Record<string, unknown>[] | null = null;
5548-
// Capture the row's FK values BEFORE deletion so roll-up summaries can
5549-
// recompute the (now-orphaned) parent. Only when a summary aggregates
5550-
// this object — avoids an extra read on every delete.
5551-
let summaryPrev: any = null;
5552-
if (hookContext.input.id && this.getSummaryDescriptors(object).length > 0) {
5553-
try {
5554-
summaryPrev = await this.findOne(object, { where: { id: hookContext.input.id }, context: opCtx.context } as any);
5555-
} catch { /* best-effort */ }
5556-
}
55575629
if (hookContext.input.id) {
55585630
// Honor referential delete behavior (cascade/set_null/restrict)
55595631
// for relations pointing at this record before removing it.
@@ -5607,9 +5679,13 @@ export class ObjectQL implements IObjectQLEngine {
56075679
await this.triggerHooks('afterDelete', hookContext);
56085680
}
56095681

5610-
// Roll-up: recompute the parent summary now that the child is gone.
5611-
const summaryFailures = summaryPrev
5612-
? await this.recomputeSummaries(object, null, summaryPrev, opCtx.context)
5682+
// Roll-up: recompute the parent summary now that the child is gone,
5683+
// from the row's FK values captured BEFORE deletion. [#5272] That
5684+
// capture is now the same single pre-image read `previous` rides on
5685+
// (it used to be its own later `findOne`), which is also what
5686+
// `update()` passes here.
5687+
const summaryFailures = priorRecord
5688+
? await this.recomputeSummaries(object, null, priorRecord, opCtx.context)
56135689
: [];
56145690

56155691
// Same split as update(): per-record `data.record.deleted` (#4626),

0 commit comments

Comments
 (0)