Skip to content

单记录 delete 从不绑定 hookContext.previous —— 契约声明「for update/delete」,引擎只在 update 分支赋值;#5038 之后批量 delete 反而比单记录 delete 更完整 #5272

Description

@os-zhuang

#5038(批量写按行语义)实现过程中发现,PD #10 单独记录,未在该 PR 内修复

事实(对 origin/main 核实)

packages/spec/src/data/hook.zod.tsHookContext.previous 声明:

Data Snapshot — The state of the record BEFORE the operation (for update/delete).

packages/objectql/src/engine.tshookContext.previous 的赋值只有一处,在 update() 的单 id 分支之后:

if (priorRecord) hookContext.previous = coerceBooleanFields(updateSchema, priorRecord);

delete() 全程不给 hookContext.previous 赋值。它确实会取一次前置行(summaryPrev),但那只在「本对象被 roll-up summary 聚合」时发生,且只喂给 recomputeSummaries,不进上下文。

所以:previous 在 delete 的任何阶段都是 undefined —— beforeDeleteafterDelete 都一样,单记录写入也一样。

为什么这不是无害的

#4775 起「条件求不出值 = 该次操作失败」。一条完全合法、照契约写的 delete 侧过渡 hook:

{ events: ['afterDelete'], condition: P`previous.status == 'done'` }

会让该对象的每一次单记录删除失败,而错误文案走的是通用分支(isPredicateBulkWrite 为 false,因为 input.id 在),读起来像作者拼错了 key —— 但 key 没错,是引擎没绑。这与 #5037 要消除的「把平台限制说成作者的错」是同一形状,只是换了一条路径。

plugin-audit 用 (ctx as any).__previous 自己在 beforeDelete 里补了一次快照,正说明这个洞已被绕过而不是被修复;而 hook-wrappers.ts 只读 ctx.previous,不读 __previous,所以 hook 条件拿不到那份兜底。

#5038 之后出现的反向不对称(这是现在值得处理的原因)

#5038predicate 批量 delete 按行派发 afterDelete,每行绑定该行的 previous(那正是删除审计所需)。于是:

写入形状 afterDeleteprevious
delete({ multi: true, where }) ✅ 该行的前置像(#5038)
delete({ where: { id } }) ❌ 恒 undefined

单记录路径现在严格劣于批量路径,这与 #4800/#4862 裁定所立的「作者写一遍,单/批含义一致」正好相反。#5038 没有顺手补这一格,因为它属于单记录路径、不在该单的范围内(越界即停)。

既有测试为何没发现

packages/objectql/src/hook-condition-previous-scope.test.ts 有一例
「a delete-shaped context evaluates previous against the pre-image」,它通过是因为测试自己手工构造了 previous;引擎从不这么给。这是一份对着并不存在的行为写的绿灯,建议连同修复一起改成走真实引擎。

可选方向(未拍板)

倾向 A,与 #5038 已落地的批量侧同源;若 A 的读取成本不可接受,B 也要求同步改 HookContext.previous 的契约文案与 skills/objectstack-formula §5 的绑定作用域表。

关联

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions