Skip to content

fix(objectql): by-id 更新不再把「已判定不是主键」的载荷 id 写进 SET 子句 (#6435) - #6475

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-6435-by-id-payload-id-strip
Aug 8, 2026
Merged

fix(objectql): by-id 更新不再把「已判定不是主键」的载荷 id 写进 SET 子句 (#6435)#6475
baozhoutao merged 2 commits into
mainfrom
claude/issue-6435-by-id-payload-id-strip

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #6435

updateby-id 臂把「派发已判定不是主键」的那份载荷 id 原样交给驱动写进 SET 子句。本 PR 把 #6262 / PR #6433 已经给 multi 臂的剥离语义,同构地带到 by-id 臂。

缺陷

update(o, { id: { $in: ['a','b'] }, title: 'x' }, { where: { id: 'rec_1' } })派发是对的,自 #5748 裁 A / PR #5919 起就是:算子对象不是主键,判定顺阶梯落到 where.id,绑定 rec_1——ENGINE_UPDATE_DISPATCH_CASES 里就写着这一行(expect: 'by-id' / expectId: 'rec_1')。没做的是载荷那一半。origin/main 实测(记录型 driver 驱动真实引擎):

driver.update('task', 'rec_1', { "id": { "$in": ["a","b"] }, "title": "x" })
                                 ^^^^^^^^^^^^^^^^^^^^^^^^ 这是 SET 子句

driver-sqlupdate()整个 dataformatted(applyWriteColumnMap(formatInput(object, data)),id 不在任何跳过名单里),于是 SQL 形如 UPDATE task SET id = '{"$in":["a","b"]}', title = 'x' WHERE id = 'rec_1'——rec_1 的行标识被一个序列化的算子对象不可逆覆盖。

前提复核(动手前逐条实测)

前提 结论 证据
P1 by-id 臂仍把未清理载荷交驱动 ✅ 成立 packages/objectql/src/engine.ts 内容定位 driver.update(object, hookContext.input.id, hookContext.input.data, …)(#6467 落地后行号漂到 6115)。探针实测六种形状,见下表
P2 PR #6433 的 by-id 现状 pin 仍在钉着 ✅ 成立 engine-update-multi-payload-id.test.ts#6262 — the by-id path is untouched describe,基线 47 tests 全绿
P3 multi 臂剥离在 main 上原样 ✅ 成立 本 PR 未触碰 else if (options?.multi …) 分支一行;对照 pin 见下

P1 探针实测(改动前,options: { where: { id: 'rec_1' } }):

data.id 派发 绑定 id 交给 driver.update 的 data
{ $in: ['a','b'] } by-id rec_1 {"id":{"$in":["a","b"]},"title":"x"} ← 缺陷
['a','b'] by-id rec_1 {"id":["a","b"],"title":"x"} ← 缺陷
null by-id rec_1 {"id":null,"title":"x"} ← 缺陷
0 by-id rec_1 {"id":0,"title":"x"} ← 缺陷
'' by-id rec_1 {"id":"","title":"x"} ← 缺陷
'rec_9' by-id rec_9 {"id":"rec_9","title":"x"} ← 路线 A 刻意不动

与 PR #6433 的同构说明

同族同构,不发明第二套:

#6262 / PR #6433(multi 臂) 本 PR(by-id 臂)
落点 分支首条语句,在 encryptSecretFields / normalize / validateRecord 之前
动作 拷贝式剥离(const { id, ...rest }),不改写调用方对象
剥离集 到达该分支即全部(multi 意味着两处都没有真值标量 id) 载荷 id 不是真值标量的那一份;真值标量 data.id 不动
成员判定 分支本身即判定 调用派发去问:resolveEngineUpdateDispatch(data, undefined).kind !== 'by-id'
verdict 变更
观测通道 logger.warn,刻意不走 onFieldsDropped 同(理由同 #6437)

成员判定这一条是本 PR 唯一的形状差异,且是刻意的:by-id 臂里载荷 id 有合法的一种(真值标量 = 被绑定的主键),所以需要一个谓词。这个谓词不在这里重新推导——asScalarId 是故意不导出的(engine-update-dispatch.ts:"给同一个问题添第三种公开写法,正是一条规则长出第二条的方式"),手抄一份正是 #4434 / #4550 这一族存在的理由。于是改为调用生产者自己的裁决:"这份载荷单独拿出来,能不能标识一行?"

范围(⛔ 未越)

data: { id: null } 回写入口 —— 分诊列为"未验证形状",本 PR 给出的结论

这条落在剥离集内(派发阶梯:asScalarId(null)undefined ⇒ 落到 where.id ⇒ 绑定 where.id,载荷的 null 与算子对象同类),已同批剥离并有测试覆盖。

REST 层是否先剥 id:否——静态读源码得出(file:line),端到端 HTTP 复现:

  1. packages/rest/src/rest-server.tsPATCH /data/:object/:id(约 :5268)只从请求体里剥 expectedVersion,id 不动;
  2. packages/spec/src/api/protocol.zod.ts:519UpdateDataRequestSchemadata 声明为 z.record(z.string(), z.unknown()),null 通过校验;
  3. packages/metadata-protocol/src/protocol.ts:5636updateData 把请求体原样交给 engine.update(object, request.data, { where: { id } })

即客户端 GET 一条记录、改两个字段、整体 PUT 回来,而序列化把 id 写成 null,就落在这条臂上。端到端 HTTP 复现未跑,如实标注。

pin 翻转清单

packages/objectql/src/engine-update-multi-payload-id.test.ts#6262 — the by-id path is untouched describe(PR #6433 写下时说"钉住,好让将来扩大剥离是一个刻意的动作"——本 PR 就是那个刻意的动作),三条中翻转一条:

用例 改动前断言 改动后断言 处置
a scalar data.id outranks multi:true …AS SENT { id: 'rec_1', title: 'x' } 不变 保留为对照 pin,补注为何刻意不动
a scalar where.id …AS SENT { title: 'x' } 不变 保留
operator data.id BESIDE a scalar where.id { id: { $in: ['a','b'] }, title: 'x' } { title: 'x' }(并加断 call.id === 'rec_1') 改写为新语义断言,标题加 [#6435] 前缀,describe 重命名为 …as this file left it and as #6435 changed it

未删除任何旧断言而不留对应新断言。

反向验证(方向先写死,再实测)

摘除方式:把剥离条件里的 resolveEngineUpdateDispatch(...).kind !== 'by-id' 换成 false,其余不动。

用例 预测 实测
① PROBE:算子对象 data.id + 标量 where.id ✅ 红
② 数组 data.id ✅ 红
null data.id ✅ 红
② 假值标量 0 ✅ 红
② 假值标量 '' ✅ 红
P2 改写后的 pin(multi 文件) ✅ 红
⑤ 调用方 data 未被变异 绿(见下) ✅ 绿
③ 标量 data.id 两条对照 pin 绿 ✅ 绿
④ multi 臂两条对照 pin 绿 ✅ 绿
engine-update-dispatch.test.ts 全部 绿 ✅ 绿
合计 6 红 / 52 绿 6 failed / 52 passed

⑤ 的方向是"绿",不是模板预设的"红" —— 如实记录。 摘掉剥离后调用方对象同样没有被变异:本实现的剥离是拷贝式的,而 by-id 臂里 encryptSecretFields / normalizeMultiValueFields 对该 fixture 无改动,两个 readonly strip 在无掉落时返回同一引用。所以这条钉的是实现形状(将来若有人改用 delete data.id 就地改写会转红,#5591 / #6343 的教训),不是剥离的存在性。按"预测方向先写死"的纪律,这里预测的就是绿,实测也是绿。

命令输出

$ pnpm --filter @objectstack/objectql test
 Test Files  145 passed (145)
      Tests  2438 passed (2438)

$ pnpm --filter @objectstack/objectql typecheck
> tsc --noEmit           (无输出 = 通过)

$ pnpm --filter @objectstack/metadata-protocol test     # by-id 调用点(消费半径)
 Test Files  53 passed (53)
      Tests  552 passed (552)

$ pnpm --filter @objectstack/rest test                  # REST 入口(消费半径)
 Test Files  64 passed (64)
      Tests  881 passed (881)

$ pnpm check:engine-double-contract
check-engine-double-contract: OK — 80 pinned, 133 in the DEBT ledger, 4 exempt.

$ pnpm check:query-options-erasure
✓ query-options-erasure ratchet holds: 83 unswept non-test site(s) in 19 file(s), none new.
  test surface: 263 site(s) in 49 file(s) — at the ceiling.

$ pnpm check:type-check-debt     (先跑全量 build:70 tasks successful,§9 陷阱)
check-type-check-coverage: OK — 62/77 workspace packages type-checked …
  ℹ @objectstack/objectql: TEST_DEBT records 355, tsc now reports 353 (-2) -- the entry can be lowered.
check-type-check-coverage --re-measure: OK — 34 ledger entr(ies) re-measured, none above its recorded number.

$ node scripts/check-nul-bytes.mjs
check-nul-bytes: OK (scanned 6093 tracked text file(s); no raw ASCII control bytes).

$ npx eslint --no-inline-config (三个改动文件)    (无输出 = 通过)

TEST_DEBT 台账未抬(355 ⇒ 实测 353,只缩不抬;按 gate 说明未强行改写台账)。

文件面

  • packages/objectql/src/engine.ts —— 仅 by-id update 臂,+75 行(其中绝大部分是理由注释)
  • packages/objectql/src/engine-update-by-id-payload-id.test.ts —— 新增,11 条用例
  • packages/objectql/src/engine-update-multi-payload-id.test.ts —— 一条 pin 改写 + 两条补注
  • .changeset/engine-update-by-id-payload-id-strip.md —— patch @objectstack/objectql

关联:#6262 / PR #6433(multi 臂那一半)、#5748 / PR #5919(data.id 的标量判定)、#5480 / #5619(派发谓词的提取与下沉)、#6437(DroppedFieldsEvent.reason 词表)、#5240 / #4434(为什么共享谓词而不是第二个答案)。


Generated by Claude Code

claude added 2 commits August 8, 2026 01:12
…e payload (#6435)

The by-id half of #6262 / PR #6433. When `data.id` is a non-scalar (operator
object, array, `null`) or a falsy scalar and `options.where.id` is a truthy
scalar, `resolveEngineUpdateDispatch` correctly rules the payload value is not
a primary key and binds `where.id` instead (#5748 / PR #5919). The dispatch was
right; the PAYLOAD was never cleaned, so `driver.update(object, 'rec_1', data)`
carried the ruled-not-an-id value into the SET clause and driver-sql wrote
`UPDATE task SET id = '{"$in":["a","b"]}' WHERE id = 'rec_1'` — the row's
identity overwritten irreversibly.

Route A only: strip that payload `id`, on a copy, leaving a truthy scalar
`data.id` exactly as it was (there the payload key IS the bound id — a
same-value no-op). Zero dispatch verdicts change; membership is asked by
calling the producer's own `resolveEngineUpdateDispatch`, never by re-deriving
the unexported scalar test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 8, 2026 1:16am

Request Review

@github-actions github-actions Bot added the size/m label Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Contributor Author

范围外发现,已按 PD #10 单独立单,未在本 PR 内修改:#6479

读 REST/协议 ingress 核实本 PR 的 data: { id: null } 可达性时顺带看到的另一条:PATCH /data/:object/:id 里,请求体的真值标量 id 会压过路径 :id(#5748 裁 A 的载荷优先规则),于是存在性探测与 OCC 判在路径那一行、写却落到请求体那一行、响应的 idrecord 互相矛盾。

与本 PR 的边界:本 PR 只剥「派发已判定不是主键」的那份载荷 id,真值标量 data.id 按路线 A 刻意保持原状并钉了对照 pin——#6479 的落点在 REST/协议 ingress,不在引擎的 by-id 臂,严重度留给分诊裁。

CI:22 项检查全部完成,0 失败(Check Changeset / ESLint / TypeScript Type Check / Test Core 1-3 / Dogfood Regression Gate 1-3 / Build Core / Temporal Conformance 均绿)。


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

update 的 **by-id** 路径同样把非标量 data.id 交给驱动写主键列(#6262 的孪生形状,where.id 胜出时)

2 participants