fix(objectql): multi update 的 SET 载荷剥掉非 id 的 data.id (#6262) - #6433
Conversation
…payload (#6262) `update(o, { id: { $in: ['a','b'] }, title: 'x' }, { multi: true })` has dispatched correctly since #5748 / PR #5919 — an operator object is not a primary key, so it stops shadowing the ladder and the declared bulk intent is honoured (`driver.updateMany`). What that fix did not do is clean the PAYLOAD. Measured on origin/main with a recording driver over the real engine: updateMany({ object: 'probe_task' }, { id: { $in: ['a','b'] }, title: 'x' }) i.e. the driver is asked to write a serialized operator object into the primary-key column of every matched row. Five backends would each answer that differently (the #5240 / #4434 family), and on the ones that accept it the matched rows lose their identity irreversibly. Reaching the multi branch AT ALL means `resolveEngineUpdateDispatch` returned `multi`, i.e. it found no scalar truthy id in EITHER source — so whatever sits in `data.id` there is a value the engine has already RULED is not a primary key. The strip is that same answer applied one layer on, not a second opinion: a value that is not the primary key does not get to sit in the primary-key column either. - Zero verdict change: `ENGINE_UPDATE_DISPATCH_CASES` is untouched and `operator object in data.id WITH multi:true` still expects 'multi'. Rejecting the call instead (#6262 route B) would reverse that just-landed case — a partial rollback of #5748's ruling A, which needs a fresh decision. - No reachable legitimate write is lost: a truthy scalar `data.id` outranks both `where` and `multi` and never reaches this branch, and N rows cannot share one primary key anyway. - The by-id path is unchanged and pinned as-is: `driver.update` takes the primary key in its own argument, so the key in the payload is redundant rather than damaging. - Falsy scalars keep the #5747 / #5748 dispatch semantics (still 'multi') and are stripped on the same argument — stripping operator objects while leaving `{ id: 0 }` in would be a second rule about one fact. The drop logs at warn, naming the consequence and both correct spellings. Deliberately not routed through `onFieldsDropped`: `DroppedFieldsEvent.reason` is a closed enum over the two read-only strips (#3407 / #3042), and widening that vocabulary is a `packages/spec` change with its own consumers. Fixes #6262 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…ti-payload-id-strip
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
|
范围外发现(PD #10,均未在本 PR 内修改)
Generated by Claude Code |
Fixes #6262
按分诊评论「Scope as queued = route A only」执行:只做 A 案(派发层剥离),零 verdict 变更,B 案(响亮拒绝)不在本次范围。
问题
update(o, { id: { $in: ['a','b'] }, title: 'x' }, { multi: true })的派发自 #5748 裁 A / PR #5919 起就是对的:算子对象不是主键,不再遮蔽派发阶梯,声明的 bulk intent 照做,调用落到driver.updateMany。#5919 没做、#5922 也按 PD #10 明确留在范围外的,是载荷那一半。实测复现(worktree @
origin/main,记录型 driver 驱动真实引擎,新增测试在打补丁前的失败断言原文):与 issue 正文的 PROBE 逐字一致:驱动被要求把一个序列化的算子对象写进每一条命中行的主键列。五个后端会对这件事各给一个答案(#5240 / #4434 家族),而在接受它的后端上,命中行的身份不可逆地丢失。
修法
packages/objectql/src/engine.ts的 update multi 分支载荷组装点(分支第一件事,encryptSecretFields之前):载荷带id键时剥掉,并按warn记一条点明后果与两种正确写法的日志。论证只有一句:走到 multi 分支本身就意味着
resolveEngineUpdateDispatch答了multi,即它在两个 id 来源里都没找到真值标量 id —— 所以此刻data.id里的任何东西(算子对象、数组、null、假值标量)都是引擎已经裁定不是主键的值。剥离是同一个问题的同一个答案多用在一层上,不是第二个答案:不是主键的东西,也就不该坐在主键列上。必答项一:与 #5922 / #5748 的语义一致性
与 #5748(裁 A / PR #5919)一致 —— 这是它的另一半,不是它的回退。
#5748 把
data.id送进了和where.id同一个标量测试,答案是「这个data.id不是主键」;它据此做了一件事(不再遮蔽阶梯,multi照做)。本 PR 据同一个答案做第二件事(不再留在主键列位置)。ENGINE_UPDATE_DISPATCH_CASES一行未动,operator object in data.id WITH multi:true仍是'multi',engine-update-dispatch.test.ts用真实引擎逐条驱动的 25 例全绿(该文件 36/36)。反过来的 B 案要反转这条刚落地的 case,那才是对裁 A 的部分回退。与 #5922 一致 —— 它留下的正是这条轴,而不是校验轴。
#5922 收口的是「声明值为标量的字段」上的算子对象,走
record-validator;id被SKIP_FIELDS按设计跳过(引擎自有列),因为这一格的裁定写在派发层。若改在 record-validator 里拒收同一个调用,就是对同一个问题给出第二个答案 —— 正是engine-update-dispatch.ts这一族模块被抽出来防止的事(#4550 / #4434)。本 PR 落在派发已给出的答案上,record-validator.ts一字未动,两条轴仍各管各的。「一个问题一个答案」不被破坏的可检验形式:唯一的判定仍只有
resolveEngineUpdateDispatch一处;剥离不重新问「这是不是 id」,而是消费分支本身携带的答案(kind === 'multi'⇒ 无 id)。代码里没有第二个标量测试、没有??兜底、没有手抄的if。必答项二:B 案(响亮拒绝)将来若裁定,要动哪里(只答不做)
packages/metadata-core/src/engine-update-dispatch.ts—— 判定本体。resolveEngineUpdateDispatch需要新增一个reject前置:data.id存在且非标量真值 且options.multi为真 ⇒reject(今天这条落在if (options?.multi) return { kind: 'multi' })。同文件的ENGINE_UPDATE_DISPATCH_CASES里operator object in data.id WITH multi:true与array data.id with multi:true两例的expect由'multi'改为'reject',模块头 point 2 的叙述需要重写(「不再遮蔽阶梯」变成「非标量data.id本身即拒绝理由」)。新拒绝语句大概率需要一条独立的 message 常量,而不是复用ENGINE_UPDATE_REJECT_MESSAGE(「既没点名一行也没声明 bulk」和「声明了 bulk 但载荷里塞了个非 id 的 id」是两种不同的作者错误,共用一句话会把诊断打回原点)。packages/objectql/src/engine.ts—— 生产者侧。update()末尾那条else { throw new Error(ENGINE_UPDATE_REJECT_MESSAGE) }是 hook 改写后重问判定的地方,需要按新 message 分叉;本 PR 加的剥离块整块删除(拒绝之后没有载荷可剥)。assertEngineUpdateDispatch上的假引擎自动跟进(这正是该模块存在的理由,不需要逐个改);但scripts/check-engine-double-contract.mjs的 DEBT 账本里那 133 条未钉的替身会开始与生产者分歧,需要重新测量。update_record把新拒绝映射成 4xx 而非 500 —— 属packages/rest的mapDataError与 automation 侧执行器,拒绝语义落地时才有意义。data.id不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式options.multi: true#5748 裁 A 的部分回退,而 A 与 B 在同一个业务场景上给用户不同的东西 —— A 让「声明了 bulk intent 就照做」继续成立(作者多写了个id谓词,行集由where决定),B 认为这种调用形状本身即作者错误、必须响亮。这是产品判断,不是实现判断。变更清单
packages/objectql/src/engine.tsid剥离 + 论证注释(+51)packages/objectql/src/engine-update-multi-payload-id.test.ts.changeset/engine-update-multi-payload-id-strip.md@objectstack/objectqlpatch⛔ 未触碰:
ENGINE_UPDATE_DISPATCH_CASES/ metadata-core 全包、record-validator.ts、非 multi 路径、事务区(#6403)、自增、剥离时序区(#5591 / #6343)、summary、content/docs/releases/。测试
新增
packages/objectql/src/engine-update-multi-payload-id.test.ts,11 例三组:null的data.id+multi⇒updateMany载荷无id键,title照常落地;调用方传入的载荷对象不被就地改写(剥离走浅拷贝,与本路径其它 strip 一致)。where.id侧不变:multi且载荷从未带 id ⇒ 载荷与行域 AST 双双原样;where: { id: { $in: [...] } }仍由 AST 选行,载荷不动。{ id: 0 }/{ id: '' }+multi的判定仍是multi(engine-delete-dispatch 的共享判定与 ObjectQL.delete 在「假值标量 id」上不一致 ——where: { id: 0 }判定答 by-id,引擎却 reject #5747 / ObjectQL.update 的data.id不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式options.multi: true#5748 语义,原样);单 id 路径(data.id标量压过multi、where.id标量、以及 ObjectQL.update 的data.id不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式options.multi: true#5748 的「算子data.id旁有标量where.id」头号形状)全部driver.update,载荷按原样送达 —— 主键走独立参数,载荷里的id是冗余而非破坏,本 PR 不动它,并按现状钉死,使将来任何扩大剥离范围的动作都必须是刻意的。反向验证(方向:红,如预测)
肢 A = 去掉剥离。这次的测量顺序天然就是这个实验:测试文件先在未打补丁的
origin/main上跑,engine.ts一字未改 —— 5 例红,且失败信息直接印出问题载荷:打补丁后同一文件 11/11 绿。注意
does not mutate the payload object the CALLER handed in一例在补丁前就是绿的(引擎当时根本不剥,自然不会改到调用方对象)—— 它是对修法的护栏,不是复现用例,如实记在此处而非充作反向证据。同一次运行还证明了「只有这 5 例动了」:补丁前整包
5 failed | 2327 passed (2332),补丁后2332 passed (2332),总数一致。命令与实测输出
消费半径(multi 分支的下游调用者)另跑:
合并
origin/main(至7618ee814)后按 AGENTS.md §10 重跑:packages/spec在对侧动过,故pnpm --filter @objectstack/spec build && check:generated→All 10 generated artifacts are up to date;objectql全量 test + typecheck 复跑仍全绿。Generated by Claude Code