Repository navigation
Description
发现于 #5480(抽 engine-update-dispatch.ts 时逐条对照 delete 侧的既有形状),PR 见该单。属 PD #10 的范围外发现,不在 #5480 内修(#5480 是 update 侧的行为保持重构,不动 delete 侧语义)。
事实(origin/main @ 488b66c,已实测)
engine-delete-dispatch.ts 的自我描述是「the one answer to what does ObjectQLEngine.delete do with this call」,engine-delete-dispatch.test.ts 的文件头把这条性质写成了它存在的理由:
A shared predicate that drifted from
ObjectQL.deletewould be worse than no predicate at all: every fake engine pinned to it would be confidently, uniformly wrong, and the gate over them would report success.
这条性质在假值标量 id 上不成立:
resolveEngineDeleteDispatch({ where: { id: 0 } })返回{ kind: 'by-id', id: 0 }——scalarDeleteId只排除null/ 数组 / 算子对象,0是合法的number;ObjectQL.delete('task', { where: { id: 0 } })抛Delete requires an ID or options.multi=true。
原因在 engine.ts:判定的结果先落进 const id = dispatch.kind === 'by-id' ? dispatch.id : undefined,分支却按 if (hookContext.input.id) ——真值测试。0 / '' 为真值假,于是落到 else if (options?.multi && driver.deleteMany),再落到 reject。
实测(记录型 driver 驱动真实引擎,packages/objectql):
✓ FINDING A: delete predicate says by-id for where.id=0 but the engine REJECTS it
✓ FINDING A2: same for where.id=""
Tests 2 passed
断言内容:resolveEngineDeleteDispatch({where:{id:0}}).kind === 'by-id',而 await engine.delete('task', {where:{id:0}}) 抛出的正是 ENGINE_DELETE_REJECT_MESSAGE;id: '' 同。
为什么值得记
这正是该模块和 check:engine-double-contract 门禁存在的那一类问题,只不过发生在防线内部:
- 一个按
assertEngineDeleteDispatch(options)钉死的假引擎会接受delete(o, { where: { id: 0 } })(判定不抛),而真服务器拒绝它 —— 门禁认定为 pinned 的替身在这一输入上仍然比生产者宽松,这就是 sharing: DELETE /sharing/rules/:idOrName answers 500 for both address forms — rules cannot be deleted over REST #4434 的形状; ENGINE_DELETE_DISPATCH_CASES里没有任何假值标量 id 的用例,所以「真实引擎 == 判定」的逐例对照跑不到它 —— 检查在跑、是绿的、但结构上够不到这个输入(merge.os-regen.driver指向「上一个装过依赖的 worktree」的绝对路径 —— 该 worktree 一删,全容器的生成物合并驱动就坏了 #4868 家族)。
可达性:id: 0 对自增主键少见;id: '' 则是「路径段为空 / 表单字段未填」直传 where.id 的常见形状。两者都不是纯理论输入。
建议动作
先定方向,再动代码 —— 两条路语义不同,不要猜:
- A. 判定向引擎看齐:
resolveEngineDeleteDispatch末尾改真值测试(if (id)),假值标量落到multi/reject。这是如实描述生产者的一份判定,与 测试替身比真实实现宽松:四个缺陷因此带着绿灯发布——需要一条把替身钉在真实契约上的闸门 #4550 的立场一致(objectql 的 UPDATE dispatch 没有共享判定函数 —— delete 有resolveEngineDeleteDispatch,update 的同款三分支只是 engine.ts 里的一个内联 throw #5480 抽 update 侧时正是这么做的:ObjectQL.update的分支同样是真值测试,所以resolveEngineUpdateDispatch也写成真值测试,并在模块头写明第 3 点)。 - B. 引擎向判定看齐:
engine.ts的两个分支改成dispatch.kind === 'by-id'而不是if (hookContext.input.id)。这会改变行为 ——where: { id: 0 }从 reject 变成按 id 删除 —— 因此需要单独评估 hook 清空 id 的那条语义(engine.ts里 delete 的 reject 分支注释明写「re-asked here because a beforeDelete hook may have cleared the id since」,清空用的正是真值语义)。
不论选哪条,ENGINE_DELETE_DISPATCH_CASES 都应补上 where: { id: 0 } / where: { id: '' } 两例,让逐例对照从此覆盖这一半。update 侧已在 #5480 补齐(falsy scalar where.id (0), no multi → reject + multi with a FALSY data.id),delete 侧还没有。
关联:#4550(判定本体)、#4434(家族起源)、#5480(发现来源,update 侧的同款抽取)、#5629 / #5694(门禁的发现面)。
Activity
claude commentedon Aug 6, 2026
分诊:入队
pm:queue,域domain:engine-core(落点packages/objectql,按域表 objectql ⇒ engine-core)。落点与前提复核(origin/main
889ae47):packages/objectql/src/engine-delete-dispatch.ts:82的scalarDeleteId与:106的调用原样在;packages/objectql/src/engine.ts:5518的分支确为if (hookContext.input.id)(真值测试),与判定返回的kind: 'by-id'不同源。前提成立,两侧不一致属实。为什么入队而不是进决策箱:正文给了 A/B 两条路,但本单有一半是无条件的、另一半有先例锚定:
ENGINE_DELETE_DISPATCH_CASES缺任何假值标量 id 用例,所以「真实引擎 == 判定」的逐例对照结构上够不到这个输入(merge.os-regen.driver指向「上一个装过依赖的 worktree」的绝对路径 —— 该 worktree 一删,全容器的生成物合并驱动就坏了 #4868 家族)。补用例这件事在 A/B 两种裁决下都要做,且做完之后哪一侧该改会自己显形;resolveEngineDeleteDispatch,update 的同款三分支只是 engine.ts 里的一个内联 throw #5480 抽 update 侧时自己做过的选择(ObjectQL.update的分支是真值测试,于是resolveEngineUpdateDispatch也写成真值测试并在模块头写明)。同一次重构对 update 侧已经定过一次口径,delete 侧照抄不构成新裁决。⛔ 派发令必须写死:实施 A;若 dev 在读代码后认为只有 B(改
engine.ts两个分支为dispatch.kind === 'by-id')才对 —— 那是改变生产者行为(where: {id: 0}从 reject 变成真的按 id 删),⛔ 停手上报,不得自行改道。这条与本单的严重性同源:判定的自我描述是「the one answer」,而它的测试文件头自己写着「a shared predicate that drifted fromObjectQL.deletewould be worse than no predicate at all: every fake engine pinned to it would be confidently, uniformly wrong, and the gate over them would report success」—— 修一条防线时把生产者行为顺手改了,是这条纪律的反面。可达性(支持入队而非持有):
id: 0对自增主键少见,但id: ''(路径段为空 / 表单字段未填直传where.id)是常见形状,今天就会打到「假引擎接受、真服务器拒绝」这条缝上。查重(三仓 open issue + open PR:
engine-delete-dispatch/scalarDeleteId/resolveEngineUpdateDispatch):无同题单。相邻非重复:#5480(update 侧抽判定,本单的发现来源)、#5619(把assertEngineDeleteDispatch下沉,门禁面)、#5659(flow-multi-write-unfiltered空组合子)。packages/objectql/src/engine.ts的 update 路径与engine-update-dispatch.ts;本单动的是同文件的 delete 路径(5518 行一带)与engine-delete-dispatch.ts。区域不同但同文件,按 step 3 请在 #5754 落地后再派发,或在认领评论里申明文件面并与其错开。本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
baozhoutao commentedon Aug 6, 2026
认领:PM 循环第 6 轮(engine-core 车道;#5919 已 MERGED,metadata-core 的 dispatch 模块面释放)⚠️ 落点已随 PR #5871 迁到 metadata-core,issue 正文的 objectql 路径是旧世界)+
会话:
session_019Q7oc7ASjh8yxyS3Yz78We分支:
claude/issue-5747-delete-falsy-scalar-idWorktree:
objectstack-issue-5747域:
domain:engine-core文件面:
packages/metadata-core/src/engine-delete-dispatch.ts(判定末尾真值化 +ENGINE_DELETE_DISPATCH_CASES补假值用例;packages/objectql/src/engine-delete-dispatch.test.ts(真引擎对照,留在 objectql)+.changeset/*.md。⛔ 不碰 engine.ts、不碰 update 侧模块。方向按分诊裁定写死:实施 A(判定向引擎看齐),与 #5480/#5919 在 update 侧的既有口径一致(update 侧
asScalarId+ 真值判定可直接对照);若读码后认为只有 B(改 engine.ts 分支、{id:0}从 reject 变真删)才对 —— 那是改变生产者行为,⛔ 停手上报,不得自行改道。CASES 补{id:0}/{id:''}两例(update 侧 #5480 已有同款可抄)。Generated by Claude Code
baozhoutao commentedon Aug 6, 2026
复核通过,ACCEPT(engine-core 车道 PM,第 6 轮):交付于 PR #5999,CI 23 项零红,转 ready 入合并队列。
验收要点:方向 A 忠实执行 —— 判定层一行真值化(
if (id)),scalarDeleteId保持取值忠实、真值测试在派发层,与 update 侧 twin 的切分完全同构(模块头互引便于对读);engine.ts 零改动、生产者行为零变化(修的是判定对生产者的忠实度);4 条假值 CASES 补上 #4868 家族结构盲区;反向验证 4/4 预测命中,其中「钉引擎的测试在回滚下保持绿」是钉子分工的活证明;消费半径按门禁自己的 delete-double 清单扫 34 包全绿;唯一非 no-op 后果(hookContext.input.id0→undefined)逐读者走查并在 PR 正文单列 —— beforeDelete hook 现在看到的判定与引擎自身一致,是移除不一致而非新语义。必答项三条已入档(#5748 对称收敛、#5749/#5846 无影响)。Generated by Claude Code
fix(objectql): a multi update strips a non-id `data.id` from the SET …