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 我建议直接排除。

关联

Activity

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions