Skip to content

GET /meta/:type/:name answers two different body shapes on the same request — the cached branch (the DEFAULT) returns the bare document, the non-cached branch returns the spec-declared { type, name, item } envelope #5563

Description

@baozhoutao

发现于 #5545 的前提复核(要给 client.meta.getItem 补返回类型注解,先去 spec 锚定响应形状)。基线 origin/main @ 229d29ea4

事实

packages/spec/src/api/plugin-rest-api.zod.ts:773GET /meta/:type/:name 声明了唯一的响应 schema:

responseSchema: 'GetMetaItemResponseSchema'

packages/spec/src/api/protocol.zod.ts:193 里它是三键信封:

GetMetaItemResponseSchema = z.object({
  type: z.string(),
  name: z.string(),
  item: z.unknown(),
})

但同一条路由在 packages/rest/src/rest-server.ts:4020 的 handler 里有两条互斥分支,响应体形状不同:

  • 缓存分支(rest-server.ts:4076 的条件成立时)→ res.json(... result.data ...)(:4128)。result 来自 getMetaItemCached,而 packages/metadata-protocol/src/protocol.ts:5409-5411 里它做的正是 const item = (result as any)?.item;:5441 return { data: item, ... } —— 也就是说 result.data 是已经拆掉信封的裸元数据文档
  • 非缓存分支res.json(... visible ...)(:4238),visible = await p.getMetaItem(...),即 { type, name, item, lock, ... } 信封,与 spec 一致。

关键在于缓存分支是默认路径:packages/spec/src/api/rest-server.zod.ts:285enableCache: z.boolean().default(true),rest-server.ts:2796 也是 metadata.enableCache ?? true。于是 spec 声明的那个形状,在默认配置下反而是取不到的那一个;信封只在关掉缓存、或 app / doc / book / ?state=draft / ?preview=draft / ?package= 这些绕过缓存的读法下才出现。第三种形状还有 ?layers=true(rest-server.ts:4031-4044getMetaItemLayered),与前两者都不同。

仓内早就在注释里承认了这件事,只是当作运行时事实处理而非缺陷 —— packages/rest/src/import-prepare.ts:18-32isMetaEnvelope:

Detect the getMetaItem response envelope ({ type, name, item, lock, … }) … The cached read path and getMetaItems element shape hand back the already-unwrapped document instead, so translation helpers must distinguish the two

rest-server.ts:2631-2637 同一段话再写了一遍。isMetaEnveloperest-server.ts 有 5 处调用,全部是在为「这次拿到的到底是哪种形状」做运行时嗅探。

runtime 那条服务面同病:packages/runtime/src/domains/meta.ts:247 命中 protocol 时 deps.success(data) 送信封,而 :216-218 的 registry 兜底送 qlService.registry.getObject(name)(裸文档),:258-260 的 metaSvc 兜底同样是裸文档。

实测

在 worktree 里对着真 RestServer 跑同一条请求(GET /api/v1/meta/object/customer,无 query),只切换 protocol 是否提供 getMetaItemCached,捕获 res.json 的实参:

CACHED  BRANCH BODY = {"name":"customer","label":"Customer","fields":{}}
NONCACHED BRANCH BODY = {"type":"object","name":"customer","item":{"name":"customer","label":"Customer","fields":{}}}

(探针脚本是一次性的,未提交。)

影响

  • 同一个公开 endpoint,同一个请求,响应体结构随服务端配置而变 —— 任何消费方都只能运行时嗅探,或者干脆 as any。这正是 client SDK 的 meta.getItem 没有声明返回类型(载荷为 unknown),而并排的 meta.getItems 有 —— 同一表面上相邻两个方法的类型化不对等 #5545 卡住的原因:client.meta.getItem 想补一个诚实的返回类型注解,而现在没有一个类型是对的 —— 标信封则默认配置下撒谎,标裸文档则与 spec 声明矛盾。
  • 已有消费方按裸文档理解这条路由,与 spec 相反:packages/client-react/src/metadata-hooks.tsx:113-140meta.getItem(...) 的结果和 getCached().data(裸文档)喂进同一个 data state,而 useObject 自己的 docstring(:52-64)读的是 schema.label / schema.fields。若服务端走到非缓存分支,这些读取全是 undefined,且不报错。packages/client/src/client.test.ts:96-99 的 mock 同样是裸文档。
  • 属于「声明了却没有强制」的一类:spec 写了唯一响应 schema,生产端有三种形状,没有任何 gate 会发现。AI 生成的消费代码在这种表面上一定会长出 ??as any

处置(需定案,勿直接猜)

统一到一种形状是不可避免的,但选哪一种是公开契约决定,应由 maintainer 拍板:

  • A 全部收敛到 spec 的 { type, name, item } 信封 —— 契约优先,plugin-rest-api.zod.ts:773 本来就这么声明。代价:对今天默认配置(缓存分支)的所有读者是破坏性变更。
  • B 全部收敛到裸文档,并改 spec —— 贴合 client-react / CLI / client 自测的既有预期。代价:丢掉 type / name / lock,而 lock 是 ADR-0008 OCC(If-Match)那条链在用的,得另找承载;且「裸元数据文档」本身是个很弱的类型,收窄价值有限。

无论哪条,都应在同一次里把 isMetaEnvelope 的运行时嗅探删掉 —— 它存在本身就是这个分裂的度量。

关联:#5545(被本单阻塞)、#3847(同族:声明了富形状、两个 surface 都发裸值)。

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions