Skip to content

fix(objectql): 单记录 delete 绑定 hookContext.previous —— 让引擎符合契约已声明的「for update/delete」 (#5272) - #5283

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5272-single-delete-previous
Aug 4, 2026
Merged

fix(objectql): 单记录 delete 绑定 hookContext.previous —— 让引擎符合契约已声明的「for update/delete」 (#5272)#5283
os-zhuang merged 2 commits into
mainfrom
claude/issue-5272-single-delete-previous

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5272

按 issue 的 A 方向实施(PM 已拍板):delete() 复用 update() 同款的 demand-driven 前置行门,在单 id 路径取一次前置行并赋给 hookContext.previous,覆盖 beforeDelete / afterDelete 两个阶段。⛔ 未触碰 packages/spec/**content/docs/releases/**metadata-protocol/src/protocol.tscore/src/kernel.ts,也未改 plugin-audit 的 __previous 兜底。


一、STALE-PREMISE 事实核对(基于合并 #5270 之后的现场)

worktree 基于 3905c0064(即 PR #5270 的合入点),逐条核对 issue 正文引用的行为:

issue 的说法 合并后现场 结论
hookContext.previous 的赋值只有一处,在 update() 单 id 分支 现在是两处:update() 单 id 分支(engine.ts 原 5300 行),以及 #5270 新增的 buildPerRowAfterContexts()(原 1225 行),后者为批量 update 和批量 delete 的每行 after 上下文赋 previous ⚠️ 已过时,但结论更强:单记录 delete 是合并后唯一一条完全不绑 previous 的写入路径
delete() 全程不给 hookContext.previous 赋值 成立(修复前 grep previousdelete() 体内零命中) ✅ 成立
summaryPrev 只喂 recomputeSummaries,不进上下文 成立(原 5551–5556 读,5611–5613 唯一消费点) ✅ 成立
报错走通用分支、读起来像作者拼错 key 已用真实引擎复现,原文如下 ✅ 成立

复现原文(在 stash 掉本 PR 的 engine.ts 改动、只跑新测试时打印):

Hook 'audit_completed_task_deletion' could not evaluate its condition
(type: Unknown variable: previous) — operation aborted. The condition reads
'previous', which is not bound for this operation — the scope holds 'record',
plus 'previous' only when the record's prior state is in hand.

补充一条 issue 未提、实施中确认的事实:summaryPrev 走的是引擎级 this.findOne()(过中间件、RLS、formula 投影),而 update() 喂给 recomputeSummariespriorRecord裸 driver 读。同一件事两条路两种语义 —— 合并成一次读时按 update() 的口径统一(见下)。


二、实现摘要(packages/objectql/src/engine.ts)

前置行读取放在 triggerHooks('beforeDelete') 之前:delete 的 before 阶段是唯一「除了 id 什么都没有」的阶段(inputdata),而前像本来就必须在删除动作前取,一份数据两个阶段共用。

门(demand-driven):

hasHooksFor('beforeDelete', object) || hasHooksFor('afterDelete', object)
  || getSummaryDescriptors(object).length > 0

合并为一次读:原先 roll-up summary 的 summaryPrev另一次、更晚的读(在 beforeDelete 之后)。现已删除,recomputeSummaries 直接吃这一次前置行。口径按 update() 统一为driver.findOne —— recomputeSummaries 只取 p?.[desc.fkField](存储列),裸读足够,且不会因调用方读权限被 RLS 收窄而静默跳过父行重算。

租户/事务安全:裸 driver 读经 buildDriverOptions(object, opCtx.context, hookContext.input.options) 取得 options —— 这正是把开启中的事务和 tenantId 带到裸读上的那一层,update() 的前置读同理。少了它会读到事务外、甚至跨租户。

不臆造:行不存在 → previous 保持 unbound(而非 {} / null),条件读它仍按 #4649/#4775 大声失败,而不是替一条没人读过的记录作答。

id 被改写/清空的收尾:beforeDelete hook 可以改写甚至清空 input.id(#4550 的 reject 判定就是为此再问一次)。hook 跑完后若 input.id !== id,则重读(改写)或丢弃(清空)绑定 —— 陈旧前像绝不进入 afterDelete 或 summary 重算;清空后落到 predicate 分支,而批量派发按契约本就不该带任何单行前像(hook-wrappers 正是靠这个「两者皆无」来诊断批量派发)。

未动:#5038 的批量 predicate delete 按行绑定路径逐字未改。


三、测试

改造既有「开绿灯」的 pin(本单硬性要求)

hook-condition-previous-scope.test.tsa delete-shaped context evaluates 'previous' against the pre-image 手工构造 previous,断言的是 wrapper 读没读它 —— 而引擎从不产出这份数据,于是它替一个不存在的行为发了绿灯,真正的故障(每一次带 previous.* 条件的单记录 delete 都被 #4775 打回)就藏在它后面。已删除,原位留下说明注释,替换为走真实引擎的端到端用例。

新增 8 例(全部 insert → 真实 engine.delete() → 断言 hook 拿到什么)

用例 断言
hands 'beforeDelete' the stored pre-image ctx.previous 等于数据库前像,不是 undefined、也不是 delete input 的裸 {id}
hands 'afterDelete' the same pre-image — by then the row is gone 同一份前像;并先断言此刻该行已读不到,坐实「必须提前取」
evaluates a 'previous.*' delete-side condition instead of rejecting the delete (#4775) issue 原样的 previous.status == 'done':两次 delete 都不被拒,且条件有选择性(只对 done 的那条触发)
is TOTAL over declared fields on a delete too 从未写过的列在 delete 侧同样物化为 null,不中断表达式
does not leak materialised nulls into what the delete hook observes 引擎自己的 ctx.previous 不会多出该行从未有过的列
reads the pre-image ONCE for both phases before+after 两个 hook 都在时,findOne 增量 = 1(合并读的成本护栏)
reads nothing at all when the object has no delete-side hook 增量 = 0(demand-driven 门)
leaves 'previous' UNBOUND when the row is not there 拿到 undefined,不臆造

反向验证(证明这些是真 pin,不是重言式):把 engine.ts 的改动 stash 掉单跑该文件 —— 6 failed | 17 passed,失败原因正是 issue 描述的两类(expected undefined to deeply equal {...}HookConditionError ... Unknown variable: previous);另 2 例(无 hook 不读、行不存在不绑)修复前后都应成立,故不失败。恢复改动后 23 例全绿。

验证记录

命令 结果
pnpm --filter @objectstack/objectql test Test Files 116 passed (116) / Tests 1875 passed (1875)
pnpm --filter @objectstack/objectql typecheck tsc --noEmit,无输出(通过)
pnpm exec eslint packages/objectql/src/engine.ts packages/objectql/src/hook-condition-previous-scope.test.ts 无输出(通过)
turbo run test --filter @objectstack/plugin-audit --filter @objectstack/trigger-record-change plugin-audit 108 passed;trigger-record-change 55 passed(两者都读 ctx.previous,是本改动的直接下游)

summary-rollup.test.ts(含 recomputes when a child is deleted)与 engine-summary-retry.test.ts 覆盖了被合并掉的那次读,均绿。

重的构建/测试全程持 flock /tmp/os-heavy-verify.lock 并带 NODE_OPTIONS=--max-old-space-size=4096,--maxWorkers=2 / --concurrency=2


四、变更记录

.changeset/single-delete-binds-previous.md(@objectstack/objectql: patch),写明行为变化与升级影响:原本因 #4775 被打回的 delete 侧 previous.* 条件现在正常求值;delete 侧 handler 开始收到 ctx.previous;若有人靠 ctx.previous == null 来判别「这是一次 delete」,该判据失效,应改读 ctx.event。未改 content/docs/releases/**


五、范围外(未在本 PR 修,留给 PM 分诊)

  1. plugin-audit 的 (ctx as any).__previous 兜底可退役(PM 已记为后续 finding,本 PR 按指令不动)。audit-writers.tscaptureBeforebeforeDelete自己再读一次前像塞进 __previous;单记录 delete 的 previous 落地后,这次读在 delete 路径上已成重复(update 路径本来就重复)。退役它可省掉一次读,但属独立面。
  2. update() 的前置行门仍是全局的(this.hooks.get('afterUpdate')?.length > 0),而 delete 侧用的是按对象的 hasHooksFor。全局门意味着任一对象注册了 afterUpdate,所有对象的单 id update 都多付一次读。属可收窄的性能面,非缺陷,未在本单扩面。

以上两条尚未开 issue —— 均为 PM 已知/已记项与纯优化面,按 #4949 的立项纪律先在此列明,若需要我可另开 finding 标签的观察类 issue。


Generated by Claude Code

claude added 2 commits August 4, 2026 15:44
…5272)

`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
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 3:45pm

Request Review

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Contributor Author

范围外清单补记(正文第五节写这两条时尚未立项,现更新):


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 16:06
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit db2ea82 Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5272-single-delete-previous branch August 4, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants