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

关联

Activity

  1. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    分诊(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

  2. self-assigned this
    on Aug 4, 2026
  3. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    认领(PM 循环,会话 session_01Pbu27iNUfQCHeuS551Rqo7):PR #5270 已合入 origin/main(3905c0064),阻塞解除,pm:blocked → pm:dispatched,现在派发。

    • 分支:claude/issue-5272-single-delete-previous
    • worktree:/home/user/objectstack-issue-5272
    • 域:domain:engine
    • 文件面:packages/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

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions