Skip to content

fix(metadata-protocol): PATCH /data/:object/:id binds the PATH row, not the body's id (#6479) - #6709

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-6479-patch-path-id-wins
Aug 8, 2026
Merged

fix(metadata-protocol): PATCH /data/:object/:id binds the PATH row, not the body's id (#6479)#6709
os-zhuang merged 3 commits into
mainfrom
claude/issue-6479-patch-path-id-wins

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#6479

结论先说

PATCH /data/:object/:id 这一条 ingress 上,行标识以路径 :id 为准。请求体里的标量 id 不再能把写落到别的行上。

实现落在 packages/metadata-protocol/src/protocol.tsupdateData 区,一处派发前的载荷合并;引擎、packages/spec 都没动。

前提复核(在 origin/main 上逐条读过,不是照抄 issue)

Issue 说的三方打架,在合并后的 main 上完整成立(行号已漂移,以下按当时 e6025e9 重新定位,现已再合并至 d6d1a50be):

  • protocol.ts:5743const current = await this.probeRecord(request.object, request.id) —— 存在性探测判路径 id;
  • :5748this.assertVersionOf(request.object, request.id, current, request.expectedVersion) —— OCC 判路径 id;
  • :5749const opts = { where: { id: request.id } },:5759await this.engine.update(request.object, request.data, opts) —— request.data原样下发;
  • :57605764 回执 { id: request.id, record: result }

packages/metadata-core/src/engine-update-dispatch.ts 的规则是载荷优先,案例表里写着 a SCALAR data.id still wins over a scalar where.id(expectId: 'rec_1')。两者相加:PATCH /data/task/rec_1 带 body {"id":"rec_2","title":"x"} 会探测 rec_1、按 rec_1 校 If-Match写 rec_2、再回一个 id: rec_1 配 rec_2 的回读。rec_2 从未被探测、也从未被 OCC 校验。

线上到引擎之间确实没有任何一层剥 id:rest-server.ts 的 PATCH 路由只解构掉 expectedVersion;UpdateDataRequestSchemadata 声明为 z.record(z.string(), z.unknown())

做法(裁决路线 A)

派发前把路径 id 盖到载荷上,和批量 ingress 对同一问题的既有答案同形:

// 单条(本 PR)
{ ...request.data, id: request.id }
// 批量(rest-server.ts,一直如此)
ql.update(op.object, { ...data, id }, …)

于是探测的行、OCC 校的行、真正写的行、回执 id/record 指的行,是同一行 —— URL 里那一行。一个仓库对「一次单行写绑定哪一行」只剩一个答案(#4550 / #4434 那一族)。

几点边界,都是刻意的:

测试

新增 packages/metadata-protocol/src/protocol.update-path-id-wins.test.ts(16 例)。钉的是 ingress 级不变式,不是「这行代码在」:探测的行、OCC 校的行、写的行、回执的 id/record,是同一行 —— 路径行。

fake engine 的 update 不自己复刻规则,而是调用 producer 的 assertEngineUpdateDispatch(data, options)服从它给出的绑定行(delete 同样开在 assertEngineDeleteDispatch(options))。这一点是关键:若在这里手抄一个 if (opts?.where?.id),每条用例都会写 rec_1,整套测试会把「早就修好了」报成绿。

覆盖:

  • (a) body id === 路径 id —— 最常见的「GET 一条、改字段、整份 PUT 回来」仍然成功,落在路径行;
  • (b) body id ≠ 路径 id —— 写落在路径行,另一行未被触碰(断言的是它存下来的行体,不是返回值);回执 idrecord.id 不再互相矛盾;body id 指向一条不存在的行时,仍然写路径行(修复前这里回的是 { id: 'rec_1', record: null });
  • (c) OCC —— 匹配路径行的 If-Match 放行,且放行的是路径行的写;过期 token 是 CONCURRENT_UPDATE / 409 且两行都没动;送另一行的版本号(正是 body 指向的那行)照样 409,currentVersion 报的是路径行的 —— OCC 判的是路径行,现在守的也正是被写的那一行;
  • (d) 对照钉:路径 id 指向空行仍是 RECORD_NOT_FOUND / 404 且 engine.update 完全未被调用;无 id 的普通 PATCH、非标量 / 假值 body id(算子对象 / 数组 / null / 0,即 update 的 **by-id** 路径同样把非标量 data.id 交给驱动写主键列(#6262 的孪生形状,where.id 胜出时) #6435 那一族)、数组载荷,全都绑定路径行;undefined / null 载荷仍然抛引擎自己的 TypeError

最后一条刻意不按 ADR-0112 断言 code + status,差别正是要点:那不是本 ingress 新装的一条拒绝,而是 ObjectQL.update 对缺失载荷不做保护地读 data.id 的调用方编程错误,原样浮出来。报告里照实说明,而不是套模板凑一个信封断言。

反向验证(方向先预测,再执行)

预测:撤掉修复后,只有「被写的行会变」的那几条转红,对照钉保持绿。

git checkout origin/main -- packages/metadata-protocol/src/protocol.ts 后重跑该文件:4 红 / 12 绿,与预测一致 ——

× (b) body id !== path id … AssertionError: expected 'rec_2' to be 'rec_1'
× (b2) 回执自洽 … AssertionError: expected 'rec_2' to be 'rec_1'
× (b3) body id 指向不存在的行 … AssertionError: expected null to match object { id: 'rec_1', title: 'x' }
× (c) 匹配的 If-Match 放行的是路径行 … AssertionError: expected 'rec_2' to be 'rec_1'
Test Files 1 failed (1) Tests 4 failed | 12 passed (16)

转绿的 12 条不是凑数:(a)、404、非标量 body id、TypeError 那几条在修复前后本来就该同样是绿,它们钉的是这次改动不许动的行为。

本地跑过的

pnpm --filter @objectstack/metadata-protocol test → 59 files / 643 tests passed
pnpm --filter @objectstack/rest test → 68 files / 1039 tests passed
objectql: engine-update-dispatch / engine-update-by-id-payload-id
/ engine-update-multi-payload-id → 3 files / 58 tests passed (#5748 / #6435 的钉未受影响)
pnpm lint → clean
turbo run typecheck (packages/* + packages/*/* + apps/*) → 120/120 successful
lint.yml 里 40 条 check:* 逐条跑过 → 全 PASS(含 check:engine-double-contract、
check:kernel-hook-pairs、check:type-check-debt)

check:type-check-debt 第一次在我自己批量脚本给的 3 GB 堆上 OOM 了,单独用 6 GB 重跑通过 —— 是我的资源设置,不是门禁判定。

后继定价(PM 要的三张卡)

范围外发现

无。


Generated by Claude Code

…t the body's id (#6479)
`updateData` probed existence and validated OCC against the path `:id`, built
`{ where: { id: request.id } }`, and then handed the request body to the engine
verbatim — where a truthy scalar `data.id` outranks `where.id`. A body of
`{"id":"rec_2"}` on `PATCH /data/task/rec_1` therefore probed rec_1,
version-checked rec_1, WROTE rec_2, and answered `id: rec_1` beside rec_2's
readback: a silent cross-row write straight past the caller's own `If-Match`.
The path id is now merged over the payload before dispatch
(`{ ...request.data, id: request.id }`) — the same shape the bulk ingress in
`rest-server.ts` has always used, so the repo's two single-write ingresses give
one answer (#4550 / #4434). Triage ruling A of 2026-08-08; routes B (400 on
mismatch) and C (schema ban) were explicitly rejected, and neither the engine's
payload-first dispatch (#5748) nor its by-id payload strip (#6435) is touched.
A non-record payload (`undefined`, `null`, an array) passes through untouched so
this ingress is never kinder than the producer about a malformed call.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw
@vercel

vercelBot commented Aug 8, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 8, 2026 1:15pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

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

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol)
  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

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.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude