fix(core): a persisted shape's accepted key set can shrink without anything noticing
- Dominant language
- TypeScript
- Stars
- 5.4k
- Forks
- 502
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 715
Description
Removing a key from an exact object shape silently makes every stored record carrying that key undecodable. This is not hypothetical — it shipped in #4879 and was caught in review: dropping `partialOutputRetained` from `TURN_STATE_MESSAGE_SHAPE` made 67 of 67 `turn_state` rows in a real user store fail `decodeMessage`, which would have left those Sessions unopenable after upgrade. Fixed in `3a4371a74` by adding a `retired` list to `defineObjectShape`.
**The invariant, stated once**: an exact shape carries two contracts whose variance is opposite — what it *emits* may shrink freely, what it *accepts* may only grow. `allowed` expressed both as one set, so a type-level field deletion was silently translated into a persisted-data-level deletion.
**Why the type system made it worse rather than better**: `Covers` requires the optional list to exactly cover the type's optional keys, so once the field left `TurnStateMessage`, *keeping* the key in `optional` was a compile error. The compiler pushed toward the breaking change.
**What is still unguarded**: `retired` names the rule but nothing enforces it. There are 242 `defineObjectShape` call sites across 24 files; `session.ts` alone holds 26 shapes and exports none of them, so no test can enumerate what any shape accepts. `durable-tool-result-projection.ts` builds 7 `ExactObjectShape` values structurally, bypassing the `Covers<>` check entirely. The epoch does not cover this either — it rejects an incompatible peer, not a row already on local disk.
**Wanted**: a check that fails when a shape's accepted key set (`allowed ∪ retired`) shrinks, and passes when it grows. The obstacle is enumeration, and it is a design decision rather than an implementation detail:
1. export every shape and enumerate them from a test (churns 24 files, makes internals public);
2. register shapes into a module-level registry inside `defineObjectShape` (no export churn, but a side effect on import and no help for the 7 hand-built shapes);
3. extract the sets from source at build time (covers hand-built shapes too, adds a build step).
A committed snapshot of the accepted sets, compared per shape, is what turns the rule from a comment into a failing test.
Not a good first issue: the fix touches a core schema authority and needs the enumeration decision made first.
简体中文
**从一个精确对象形状里移除一个键,会静默地让每一条携带该键的已存记录无法解码。** 这不是假设 —— 它在 #4879 里真的发生过,并在 review 中被抓到:把 `partialOutputRetained` 从 `TURN_STATE_MESSAGE_SHAPE` 移除后,某个**真实用户存储**里 67 条 `turn_state` **全部** `decodeMessage` 失败,那会让这些 Session 在升级后打不开。已在 `3a4371a74` 通过给 `defineObjectShape` 增加 `retired` 列表修复。
**不变量,只说一遍**:一个精确形状承载着两个**变型方向相反**的契约 —— 它**发出**什么可以自由收缩,它**接受**什么只能增长。`allowed` 把两者表达成了同一个集合,于是一次**类型层面**的字段删除,被静默翻译成了一次**持久化数据层面**的删除。
**类型系统为什么帮了倒忙**:`Covers` 要求 optional 列表**恰好**覆盖该类型的可选键;所以一旦字段离开 `TurnStateMessage`,把这个键**留在** `optional` 里反而是编译错误。**编译器把人推向了那个破坏性改动。**
**目前仍然没有守卫**:`retired` 给规则起了名字,但**没有任何机制强制它**。仓库里有 242 处 `defineObjectShape` 调用、分布在 24 个文件;仅 `session.ts` 就有 26 个形状,**一个都没有导出**,所以没有任何测试能枚举出某个形状接受什么。`durable-tool-result-projection.ts` 还以结构体方式手工构造了 7 个 `ExactObjectShape`,**完全绕过** `Covers<>` 检查。epoch 也顶不上 —— 它拒绝的是不兼容的**对端**,不是一条已经躺在本机磁盘上的行。
**想要的**:一个检查 —— 当某个形状的**接受键集合**(`allowed ∪ retired`)收缩时失败,增长时通过。障碍在**枚举**,而这是一个**设计决策**而非实现细节:
1. 导出每个形状、由测试枚举(改动 24 个文件,把内部实现变成公开面);
2. 在 `defineObjectShape` 内部把形状注册进一个模块级注册表(无需导出、但引入了 import 副作用,且救不了那 7 个手工形状);
3. 构建期从源码中提取这些集合(连手工形状一起覆盖,但多一个构建步骤)。
把接受集合**提交为快照**、逐形状比对,才是让这条规则从注释变成**会失败的测试**的那一步。
**不是 good first issue**:改动触及核心 schema 权威,且需要先把枚举方案拍板。
Contributor guide
Assessment
This issue has not been assessed yet.