Repository navigation
Description
在 #5038(批量写按行语义)实现过程中发现,PD #10 单独记录,未在该 PR 内修复。
事实(对 origin/main 核实)
packages/spec/src/data/hook.zod.ts 的 HookContext.previous 声明:
Data Snapshot — The state of the record BEFORE the operation (for update/delete).
而 packages/objectql/src/engine.ts 里 hookContext.previous 的赋值只有一处,在 update() 的单 id 分支之后:
if (priorRecord) hookContext.previous = coerceBooleanFields(updateSchema, priorRecord);
delete() 全程不给 hookContext.previous 赋值。它确实会取一次前置行(summaryPrev),但那只在「本对象被 roll-up summary 聚合」时发生,且只喂给 recomputeSummaries,不进上下文。
所以:previous 在 delete 的任何阶段都是 undefined —— beforeDelete 和 afterDelete 都一样,单记录写入也一样。
为什么这不是无害的
自 #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 之后出现的反向不对称(这是现在值得处理的原因)
#5038 让 predicate 批量 delete 按行派发 afterDelete,每行绑定该行的 previous(那正是删除审计所需)。于是:
| 写入形状 | afterDelete 的 previous |
|---|---|
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.
delete()按 update 的既有形状取前置行 —— 复用update()那条 demand-driven 门(needsPriorRecord(schema) || 有 afterDelete hook),单 id 分支取一次、赋给hookContext.previous。语义正确且与 [17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038 的批量侧对齐;代价是有 delete 侧 hook 的对象每次删除多一次读(summaryPrev在部分场景下已经在读,可合并)。 - B. 收紧契约文案,声明 delete 不绑定
previous—— 成本低,但把「删除审计拿不到被删的行」写死成平台性质,且与 [17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038 刚落地的批量侧直接矛盾(同一平台对同一件事两个答案)。 - C. 让
hook-wrappers回落读__previous—— 不采纳,记在这里只为列全:那是让消费端去容忍生产端的缺口,正是 [17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038 反过来做的事。
倾向 A,与 #5038 已落地的批量侧同源;若 A 的读取成本不可接受,B 也要求同步改 HookContext.previous 的契约文案与 skills/objectstack-formula §5 的绑定作用域表。
关联
- 发现于:[17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038(批量写按行语义)/ PR feat(objectql)!: 批量写按行语义 —— after 型 hook 按行触发,record-change trigger 按行绑定 previous/record (#5038) #5270 —— 其中批量 delete 侧已按行绑定
previous - 先例:hook 的
condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775(求不出值 = 失败)、[rc 止血] 批量写上引用 previous 的 hook 条件:泛化失败换成点名批量限制的专门诊断 + ADR 补遗 #5037(把平台限制说成作者的错 = 缺陷)、Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649(previous总全 + fail closed) - 契约面:
packages/spec/src/data/hook.zod.ts(spec 车道)
Activity
os-zhuang commentedon Aug 4, 2026
分诊(PM 循环第 4 轮,会话
session_01Pbu27iNUfQCHeuS551Rqo7):挂pm:blocked,Blocked-by: PR #5270(同文件engine.ts,且按行语义合入后本单的不对称恰好反转 —— 批量 delete 绑previous、单记录 delete 反而不绑,修法须基于合并后现场)。合入后转pm:queue优先派(#4775 fail-loud 下这是真实的写入失败面)。已知陷阱记录:既有 pin 测试手工构造previous,是对引擎从不产出的行为开绿灯 —— 修复时该测试要一并改为走真实引擎路径。Generated by Claude Code
os-zhuang commentedon Aug 4, 2026
认领(PM 循环,会话
session_01Pbu27iNUfQCHeuS551Rqo7):PR #5270 已合入origin/main(3905c0064),阻塞解除,pm:blocked→pm:dispatched,现在派发。claude/issue-5272-single-delete-previous/home/user/objectstack-issue-5272domain:enginepackages/objectql/src/engine.ts(delete()单 id 路径)、packages/objectql/src/hook-condition-previous-scope.test.ts(改手工构造previous的 pin 测试为走真实引擎);⛔ 不触碰packages/spec/**方向判定(PM):按 A 实施 ——
delete()复用update()既有的 demand-driven 前置行门,单 id 分支取一次前置行、赋给hookContext.previous。这不是新的产品语义决策:契约 TSDoc 已声明previous"for update/delete"(declared = enforced,ADR-0049 取向),#4800/#4862 已裁定单/批含义一致,#5038 批量侧已按行绑定 —— A 是唯一让引擎符合已声明契约的选项。B(收紧契约文案)与 #5038 刚落地的批量侧直接矛盾且属 spec 车道,C(消费端回落读__previous)是让消费端容忍生产端缺口,均不采纳。plugin-audit 的
(ctx as any).__previous兜底本单不动(避免扩面);修复落地后它是否退役另记 finding。Generated by Claude Code
fix(objectql): bind `hookContext.previous` on a single-record delete (o…
refactor(objectql): split the delete-side by-id repoint out of #5574'…
feat(objectql)!: retire delete()'s by-id beforeDelete REPOINT, aligni…