Skip to content

fix(metadata-protocol)!: batch 逐行结果迁移到 BatchOperationResultSchema 形状 —— 方案 B 已拍板,硬切 + 诚实迁移说明(Blocked-by #4620) #4793

Description

@xuyushun441-sys

背景

#4620 的第 3 节。第 1、2 节(deleteManyData 假原子、updateManyData 完全不读 atomic)已派发实现中,分支 claude/issue-4620-many-data-atomic。本条是被刻意留下的那一半。

这不是第一次被留下:ADR-0119 D4 修 batchDataatomic 时就已经识别出它并明确不动,理由写在代码里(packages/metadata-protocol/src/protocol.ts:814-820):

Deliberately the shape the implementation has always emitted (error: string, record), which diverges from BatchOperationResultSchema's errors: ApiError[] / data — reconciling the two is a wire-visible change that must not ride along on a bug fix (ADR-0119 D4; tracked separately).

我这次派发 #4620 时沿用了同一条理由 —— 让 wire 兼容性变更搭在 bug fix 上,正是它两次被留下的原因。但它不能一直被留下去:这是 declared ≠ delivered,而且分歧就写在两份都对外的东西之间。

具体分歧(已逐字核对,不是照抄 issue)

BatchOperationResultSchema(packages/spec/src/api/batch.zod.ts:183)声明:

key schema 实现(BatchDataRowResult,protocol.ts:821)
id string? ✅ 一致
success boolean ✅ 一致
错误 errors: ApiError[]? error: string? ← 名字与类型都不同
记录 data: RecordData? record: any? ← 名字不同
index number? ❌ 实现从不发
droppedFields DroppedFieldsEvent[]? ✅ 一致(protocol.ts:5446 等处确实在发)

也就是说:两个键名字对不上、一个键实现从不发,droppedFields 反而是对齐的。schema 被 BatchUpdateResponse.results(batch.zod.ts:249)引用,是对外响应的一部分。

可选方案

A. 放宽 schema 迁就现实(加上 error: string / record)

  • 项目长远合理性:差。 它把一次记账错误固化成契约。收完之后 schema 里会同时躺着 errorerrorsrecorddata —— 四个键表达两件事,而且没有任何规则说明什么时候用哪个(答案是「实现只发一种,另一种永远是空」)。这不是收敛,是把分歧升级成官方的双份声明。
  • 防 AI 写错:最差,而且是本仓最该防的那种错。 一个 AI 消费方读 schema 看见 errors: ApiError[],写 result.errors[0].message —— 编译、类型、schema 校验全过,运行时永远 undefined,因为实际发的是 error声明与投递不一致时,AI 会照着声明写,然后在运行时静默拿到空值 —— 这正是「宽容的消费端是 AI 批量犯错的温床」的教科书形状。A 让这个陷阱永久合法。

B. 迁实现到 schema 形状,并给响应加版本

error: stringerrors: [ApiError],recorddata,补上 index

  • 项目长远合理性:最好。 唯一一个终局只剩一份真相的方案。schema 本来就是契约,实现漂了就该把实现拉回来,而不是反过来改契约迁就漂移(Prime Directive Add comprehensive test suite for Zod schema validation #12 contract-first)。
  • 防 AI 写错:最好。 declared = delivered,读 schema 写出来的代码就是能跑的代码。
  • 代价(要如实看): 这是破坏性 wire 变更,已发布客户端里读 result.error / result.record 的会拿到 undefined。需要 major changeset,并且要确认「给响应加版本」在我们这套协议里具体怎么做 —— 如果没有现成的响应版本机制,B 就等于一次硬切。

C. 双发过渡:同时发新旧两组键,标记旧键弃用,一个版本后删掉

  • 项目长远合理性:好,是通往 B 的兼容路径。 终局与 B 相同(一份真相),中间多一个过渡期。代价是过渡期内响应体里真的有四个键,以及必须有人真的执行「一个版本后删掉」—— 没删掉的话它就退化成 A,而且是没写进 schema 的 A,更糟。
  • 防 AI 写错:过渡期内中等,终局同 B。 关键在于旧键必须在 schema 里被显式标记 deprecated,否则过渡期就是 A 的所有问题。
  • 只有当我们确认存在真实的外部客户端依赖旧键时,C 才值得付这份复杂度;如果消费方基本在仓内,B 直接切更干净。

我的建议

B,除非你知道有仓外客户端已经在读 result.error / result.record —— 那就 C,且必须把删除旧键这一步同时立成 issue、写进弃用说明,不能只靠「以后记得删」。

两条轴都指向同一个方向:长远上只有 B/C 收敛到一份真相,A 是把错误写进契约;防 AI 犯错上 A 最危险 —— 它让「照 schema 写、运行时拿空值」这条路径永久合法,而这正是我们反复在收紧的那类漏洞。A 唯一的优点是不动 wire,但它买来的兼容性是用「契约从此说谎」换的。

需要你拍板的其实是一个事实问题:仓外有没有已发布的消费方在读旧键。B 还是 C 由它决定;A 我建议直接排除。

关联

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions