Skip to content

fix(runtime): callData 的 ObjectQL 兜底对「记录不存在」统一答 404 RECORD_NOT_FOUND (#5138) - #5584

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-5138-calldata-notfound-unify
Aug 5, 2026
Merged

fix(runtime): callData 的 ObjectQL 兜底对「记录不存在」统一答 404 RECORD_NOT_FOUND (#5138)#5584
baozhoutao merged 1 commit into
mainfrom
claude/issue-5138-calldata-notfound-unify

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #5138

基于 origin/main @ c11369013(含 #5569 5aaa6fca8)。

前提复核:成立,且 protocol 路径「先测后判」的结论是不需要改

开工先在最新 main 上实测,复现 harness 用一个不注册 protocol 槽的服务组合(这正是 issue 自述「装了 MetadataPlugin 的部署走不到兜底」所要求的),同一行数据同时喂给两条路径:

FALLBACK get   : resolved  null                              -> /data 200 {data:null}
FALLBACK update: rejected  Error('[ObjectStack] Not Found')  -> code undefined, status undefined => 500
FALLBACK delete: resolved  {object,id,deleted:true}          -> 200,行从未存在
PROTOCOL get   : rejected  RECORD_NOT_FOUND / 404 / "Record ghost not found in task"
PROTOCOL update: rejected  RECORD_NOT_FOUND / 404 / "Record ghost not found in task"
PROTOCOL delete: rejected  RECORD_NOT_FOUND / 404 / "Record ghost not found in task"

三分支的分歧与 issue 描述逐条吻合。protocol 路径三个动词已经都是规范答案(#4435 落下、#5088 在批量面重申),所以按分诊锚定第 2 条,这次没有动 protocol 路径一行,只把它钉进测试;真正的缺陷全部在兜底侧。

改了什么

三个兜底分支抛同一个信封。信封不重新拼写:recordNotFoundError@objectstack/metadata-protocol 导出、由 callData 导入 —— 一个构造点,两条路径无法再漂移,也没有新造第二种 not-found 信封。它是纯工厂函数(不做任何服务解析),所以在「protocol 插件缺席」的装配上导入它零代价,而那正是兜底会跑的场合。

动词 之前 现在
get : null throw recordNotFoundError(object, id)
update throw new Error('[ObjectStack] Not Found')(无 .status) 同上
delete 无存在性检查 先探测,不存在则同上,且不下发删除

其中 update 的裸 Error 是把 4xx 事实报成 5xx 的直接原因:三个错误出口(HttpDispatcher.errorFromThrowndispatcher-pluginerrorResponseBase、endpoint executor 的 errorAnswer)都是读 .status 再读 .statusCode 再兜底 500。

delete 为什么用 find 探测,而不是读 ql.delete 的返回值

deleteData 能读返回值,是因为 IDataDriver.delete 声明了 Promise< boolean >(契约原文:true if deleted, false if not found)。但兜底里的 ql 是 ObjectQL 引擎(或 MCP 多环境路径上的裸驱动),而 IDataEngine.delete 声明的是 Promise< any > —— engine.ts:5589 把驱动结果穿过 hook 链后返回 opCtx.result。对它测 === false 是读一个契约没有承诺的信号,而且失败方向恰好是本单要修的方向(退回「删了零行却报成功」)。探测用的正是同函数里 get/update 兜底已经在跑的那一个 find

反向验证(方向先判后跑)

预判写在跑之前:还原三分支后,兜底与两个消费面的用例应变红,而 protocol 路径的 3 条与全部 happy-path 用例应保持绿(因为 protocol 本来就是对的,这次没改它)。

实跑结果 16 red / 9 green,与预判逐条一致。旧答案在 assertion 里原样浮出:

expected a RECORD_NOT_FOUND rejection, but the call RESOLVED with null
expected { code: undefined, status: undefined, ... } to deeply equal { code: 'RECORD_NOT_FOUND', ... }
expected a RECORD_NOT_FOUND rejection, but the call RESOLVED with {"object":"task","id":"definitely_not_a_row","deleted":true}
expected Error: [ObjectStack] Not Found to match object { status: 404, code: 'RECORD_NOT_FOUND' }

保持绿的 9 条正是应该绿的:protocol 三动词 3 条 + happy-path 6 条。

一处过程更正,记在这里而不是抹掉:第一次还原是不完整的 —— 脚本用的字符串替换同时命中了 updatedelete 两个分支的同一行,导致 delete 的探测其实没被删掉,只有信封退回了裸 Error。结果是 14 red / 9 green + 2 条「本该红却绿」("delete refuses BEFORE touching the store"、"DELETE no longer answers a 200 success")。那 2 条绿是还原不彻底的证据,不是用例无效 —— 探测还在,所以它们当然仍然通过。重做彻底还原后两条如期转红,才得到上面的 16/9。这也顺带说明这两条用例确实钉的是探测本身,而不是信封。

消费面

半径扫描(按规则的消费者枚举,不按改动包)

callData 的 get/update/delete 调用方逐个看过:

  • domains/actions.ts:300action-execution.ts:915(动作体加载 ctx.record):两处本来就是 try { ... if (got?.record) ... } catch { /* pass empty */ }。之前 nullgot?.record 落空,现在抛出走既有 catch,结果都是 record = {},行为不变
  • domains/mcp.tsbridge.get:packages/mcp/src/mcp-http-tools.ts:475get_record 工具同时处理 record == nullcatch,两条都产出 errorResult;而装了 protocol 的正常部署本来走的就是抛出那条,所以这次是让精简装配向生产行为收敛
  • 全仓 grep [ObjectStack] Not Found 无任何消费者依赖旧消息。
  • packages/rest/**packages/cli/src/commands/**domains/actions.ts 匿名门:未触碰。
  • qa/dogfood 两处提及 callData 的用例:一处是 RLS 拒绝断言 isError === true(两种形状都成立,且走 protocol 路径),一处的 404 是路由未匹配的 404,均不受影响。

测试

新增 packages/runtime/src/action-execution-calldata-not-found.test.ts(25 条,in-process 用例均显式 }, 60_000)),分四组:兜底三动词的信封、protocol 三动词的钉子、两条路径 field-for-field 相等(调用方真正依赖的那个断言),以及上面两个消费面。其中一条刻意不比字面量而是拿导出的工厂函数本身对照 —— 那样任何一侧重新拼写形状都无法靠改本文件里的字面量把它糊绿。

pnpm --filter @objectstack/runtime test
  Test Files  97 passed (97)
       Tests  1422 passed (1422)

pnpm --filter @objectstack/metadata-protocol test
  Test Files  42 passed (42)
       Tests  388 passed (388)

pnpm --filter @objectstack/runtime typecheck   -> tsc --noEmit, Done

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

changeset:@objectstack/runtime + @objectstack/metadata-protocol 均 patch,升级须知写明了「精简装配上这三个调用对不存在 id 的答案会变」以及装了 protocol 的部署不受影响。未触碰 content/docs/releases/

界外发现


🤖 Generated with Claude Code

https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh


Generated by Claude Code

…5138)

`callData` 是「protocol 优先,ObjectQL 兜底」,而兜底分支对同一个事实
(id 指向的记录不存在)给了三种互不一致的答案:get 回 null(/data 包成
200 {data:null})、update 抛不带 .status 的裸 Error(两个 dispatcher 出口
都兜底 500)、delete 无存在性检查直接删并回 200 {deleted:true}。

protocol 路径自 #4435 起三个动词已经都答 404 RECORD_NOT_FOUND(先测后判,
实测确认,故不改动它),所以同一个请求的答案取决于调用方看不见的东西:
部署有没有注册 protocol 槽。三个兜底分支现在抛同一个信封。

信封不重新拼写:`recordNotFoundError` 从 @objectstack/metadata-protocol
导出、由兜底导入,一个构造点,两条路径无法再漂移。

delete 的存在性检查用 find 探测而非读 ql.delete 的返回值:IDataDriver.delete
声明 Promise<boolean> 所以 protocol 能读它,但 IDataEngine.delete 声明
Promise<any>,引擎把驱动结果穿过 hook 链返回 opCtx.result —— 对它测
`=== false` 是读一个契约没有承诺的信号,且失败方向正是本单要修的方向。

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

vercel Bot commented Aug 5, 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 5, 2026 7:49pm

Request Review

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/runtime.

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

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol, @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)
  • content/docs/releases/v17.mdx (via @objectstack/runtime)
  • 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.

@baozhoutao
baozhoutao marked this pull request as ready for review August 5, 2026 19:50
@baozhoutao
baozhoutao enabled auto-merge August 5, 2026 19:50
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit 43ca399 Aug 5, 2026
23 of 24 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-5138-calldata-notfound-unify branch August 5, 2026 20:12
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…ssertEngineDeleteDispatch (objectstack-ai#5615)

PR objectstack-ai#5584(objectstack-ai#5138)新增的 packages/runtime/src/action-execution-calldata-not-found.test.ts
里两个 fake ObjectQL 引擎的 delete() 没有走 @objectstack/objectql 的
assertEngineDeleteDispatch,合入 main 后 check:engine-double-contract
(挂在 ESLint job)转红,阻塞后续所有 PR 的 ESLint。

按门禁处方第一条修:两处 delete 以 assertEngineDeleteDispatch(opts) 开头,
并用它返回的 by-id dispatch 里的 id 作为 store 键,而不是自己再从
opts.where.id 取一次。@objectstack/objectql 已是 @objectstack/runtime 的
dependencies 条目,无需新增 devDependency,也不需要 MEASURED 例外。

这与 objectstack-ai#5138 的取舍一致且互补:那个 PR 刻意不读 ql.delete 的返回值(引擎侧
IDataEngine.delete 只声明 Promise<any>),而这里收紧的是 fake 的入口谓词 ——
断言顺带证明了 callData 兜底发出的是标量 by-id 删除,即真引擎会执行的形状。


Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…ch,并收编 run-summary 的盲区实例 (objectstack-ai#5197) (objectstack-ai#5630)

同一天三个互不相同任务的 dev agent(3/3)新写假引擎全部踩 `check:engine-double-contract`
判红,错误一模一样 —— 手抄守卫、未路由 `assertEngineDeleteDispatch`(objectstack-ai#5173objectstack-ai#5191objectstack-ai#5192),各花一轮 CI 往返 ≈15 分钟;objectstack-ai#5584 的新测试是第四次同款命中(objectstack-ai#5604)。这不是门禁
漏了,防线工作正常,代价纯粹是「新增测试 + 需要假引擎 + delete 路径」这个高频组合缺一行
提交前的提示。

os-dev 定义与 pm-dispatch 派发词模板各加一行同措辞纪律,把这轮往返省在提交前。两处都点名
手抄守卫**真有洞**这一实测事实,而不只是「风格不推荐」:objectstack-ai#5173 的手抄副本放行了
`where: { id: { $in: [...] } }` —— 它看着像 id,是多行谓词,真引擎无 `multi` 时拒收。
引用的样板是「门禁绿跑时自己列出的 pinned 假引擎」而不是一个会过期的计数。

第三处是 `service-automation/src/run-summary.test.ts` 的盲区实例(objectstack-ai#5197 评论定位):它的
内联假引擎 `async delete() { return false; }` 对谓词删除照单全收,而 `delete_record` 自
objectstack-ai#5393 起转发 `multi: cfg.multi === true`,所以 `{ objectName: 'deal', filter: { stale:
true } }` 这一形状真引擎是 reject。该文件既不在 pinned 也不在 DEBT 台账 —— 门禁的形参个数
判据够不到零形参的 delete(另立 objectstack-ai#5629 记录该扫描面缺口及实测口径),所以是检测器盲区,不是
已登记的债。后果不是假设:objectstack-ai#5225 里 showcase 的清扫流从上线起每次 `acted: 0`,单测全绿。

收编后按「补声明」处置而非重写断言:该 fixture 从来就不是契约内合法的,补 `multi: true`
声明其批量意图,于是 sweep 真的到达驱动,驱动报告匹配 0 行,用例原本的主题(计数器读 0)
完整保留。执行器侧的 reject 传播已由 `builtin/crud-bulk-intent.test.ts:153` 钉住,不在此
重复。

断言同时加强,这一步是实测逼出来的而非顺手:反向验证(假引擎已收编、`multi` 撤掉)预期红,
实际**仍然绿** —— 因为 `acted: 0` 既是「删了 0 行」也是「删除被拒」留下的痕迹,原用例唯一
的断言两种情形都满足,是为空而绿。补 `res.success` 与该节点 `runs: 1 / failures: 0` 之后
同一撤销才真的判红(`expected false to be true`),用例才在读它声称在读的那件事。

Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE

Co-authored-by: os-zhuang <hr@objectstack.ai>
Co-authored-by: Claude <noreply@anthropic.com>
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 tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

callData 的 ObjectQL 兜底路径对「记录不存在」给三种不同答案(get→200 null / update→500 / delete→200 deleted:true)

2 participants