Skip to content

Commit 43ca399

Browse files
baozhoutaoclaude
andauthored
fix(runtime): callData 的 ObjectQL 兜底对「记录不存在」统一答 404 RECORD_NOT_FOUND (#5138) (#5584)
`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` 是读一个契约没有承诺的信号,且失败方向正是本单要修的方向。 Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh Co-authored-by: Claude <noreply@anthropic.com>
1 parent e0b2ea7 commit 43ca399

5 files changed

Lines changed: 478 additions & 3 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/runtime": patch
3+
"@objectstack/metadata-protocol": patch
4+
---
5+
6+
fix(runtime): `callData`'s ObjectQL fallback answers a missing record id with 404 `RECORD_NOT_FOUND` (#5138)
7+
8+
`callData` (the data bridge behind `/data`, the MCP bridge and the declarative
9+
endpoint executor) is protocol-first with an ObjectQL fallback. The fallback
10+
gave **three different answers to one fact** — that `id` names no row:
11+
12+
| verb | before | on the wire |
13+
|---|---|---|
14+
| `get` | `return … : null` | `200 { data: null }` |
15+
| `update` | `throw new Error('[ObjectStack] Not Found')` — no `.status` | **500** |
16+
| `delete` | no existence check at all | `200 { deleted: true }` |
17+
18+
The protocol path has answered `404 RECORD_NOT_FOUND` on all three verbs since
19+
#4435 (re-asserted for the batch path by #5088), so the answer to the same
20+
request depended on something no caller can see: whether the deployment
21+
registered the `protocol` slot (`MetadataPlugin` / `@objectstack/metadata-protocol`).
22+
All three fallback branches now throw the SAME envelope the protocol throws.
23+
24+
Two of these were actively harmful. `update` reported a caller mistake as an
25+
internal fault — every dispatcher exit reads `.status``.statusCode` → 500, so
26+
a 4xx fact entered error reporting and alerting as a 5xx. `delete` reported
27+
success for a row that never existed, which is the hardest class to notice: an
28+
integrator reading `200` records the cleanup as done.
29+
30+
The envelope is not re-spelled. `recordNotFoundError` is now exported from
31+
`@objectstack/metadata-protocol` and imported by the fallback, so there is one
32+
construction point and the two paths behind one `callData` cannot drift apart
33+
again.
34+
35+
**Upgrade note.** If you run an assembly WITHOUT the metadata-protocol plugin
36+
(lean hosts, and the MCP multi-env path that threads a raw driver), these three
37+
calls change their answer for a missing id — from `200`/`200`/`500` to `404
38+
{ code: 'RECORD_NOT_FOUND', message: 'Record <id> not found in <object>' }`.
39+
Deployments that DO register the protocol slot are unaffected: they already
40+
answered `404` and this release does not touch that path. A client that
41+
branched on `data === null` from `GET /data/:object/:id` should branch on the
42+
`404` instead; a client that treated `DELETE` as idempotent should treat `404`
43+
as "already gone". Declarative endpoints (`object_operation`) inherit the same
44+
answer, since they reuse `/data`'s delegation.
45+
46+
`delete`'s existence check is a `find` probe, not a read of what `ql.delete`
47+
returned: `IDataDriver.delete` declares `Promise< boolean >` and the protocol
48+
can read it, but `IDataEngine.delete` declares `Promise< any >` and the engine
49+
returns its driver's result through the hook chain — testing that for `false`
50+
would be reading a signal the contract does not promise, and it fails in the
51+
direction this fixes.

packages/metadata-protocol/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata, graftNormalizedOperators, stripReadDecorations } from './protocol.js';
4+
// [#5138] The 404 envelope every single-record path answers, exported so the
5+
// ObjectQL FALLBACK in `@objectstack/runtime`'s `callData` builds the SAME one
6+
// instead of minting a second not-found shape. See `recordNotFoundError`.
7+
export { recordNotFoundError } from './protocol.js';
48
export { createMetadataProtocolPlugin, assembleMetadataProtocol } from './plugin.js';
59
export type { MetadataProtocolPluginOptions } from './plugin.js';
610
export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js';

packages/metadata-protocol/src/protocol.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,19 @@ function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null
340340
* params, #4190 stopped dropping filters) — a write that touched zero rows
341341
* reporting 200 is that shape one level up, on the verb where it costs the
342342
* most.
343+
*
344+
* [#5138] EXPORTED, for the same "cannot disagree about it" reason one layer
345+
* out. `@objectstack/runtime`'s `callData` is protocol-first with an ObjectQL
346+
* FALLBACK, and the fallback had reinvented this fact three incompatible ways
347+
* (`get` → `null`, `update` → a bare `Error` with no status ⇒ 500, `delete` →
348+
* no check at all ⇒ `200 { deleted: true }` for a row that never existed). It
349+
* now calls THIS function, so the two paths behind one `callData` answer a
350+
* missing id identically — which is the only reason a caller may stop caring
351+
* which of them served it. Re-spelling the envelope there would have been a
352+
* second not-found envelope; `RECORD_NOT_FOUND` (#5088) is the one this repo
353+
* has.
343354
*/
344-
function recordNotFoundError(object: string, id: string | number): Error {
355+
export function recordNotFoundError(object: string, id: string | number): Error {
345356
const err = new Error(`Record ${id} not found in ${object}`) as Error & {
346357
code?: string;
347358
status?: number;

0 commit comments

Comments
 (0)