Skip to content

fix(core): a persisted shape's accepted key set can shrink without anything noticing #4998

Description

@Astro-Han

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<Expected, Actual> 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 中被抓到:把 partialOutputRetainedTURN_STATE_MESSAGE_SHAPE 移除后,某个真实用户存储里 67 条 turn_state 全部 decodeMessage 失败,那会让这些 Session 在升级后打不开。已在 3a4371a74 通过给 defineObjectShape 增加 retired 列表修复。

不变量,只说一遍:一个精确形状承载着两个变型方向相反的契约 —— 它发出什么可以自由收缩,它接受什么只能增长。allowed 把两者表达成了同一个集合,于是一次类型层面的字段删除,被静默翻译成了一次持久化数据层面的删除。

类型系统为什么帮了倒忙Covers<Expected, Actual> 要求 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 权威,且需要先把枚举方案拍板。

Activity

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workinghelp wantedExtra attention is needed

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions