孤儿数据与清理
卸载一个单元,不等于删除它写过的数据。effect 回滚只能撤销进程内注册,管不了存档字段、IndexedDB、缓存与云同步状态。因此卸载后留下残留不是违规,而是架构的固有缺口;本页规定这个缺口唯一的合法处置路径。
当前状态:归属登记与孤儿标记已完成;完整垃圾清理(GC)属 Phase 4,未实现。相关接口见 Save 插件参考,包清单字段见包清单与目录。
术语
| 术语 | 定义 |
|---|---|
| 归属登记 | 数据块写入时产生的归属元数据 |
| 孤儿标记 | 归属登记中,所属单元当前不在装载集内的状态 |
| 认领 | 单元装载并实际接触其数据块 |
| 垃圾清理(GC) | 依用户配置删除孤儿数据块的动作 |
boot:settled | 全部扩展内容装载完成、且首帧渲染完成之后的时刻 |
为什么卸载时不能删
| 事实 | 后果 |
|---|---|
| 单元卸载只撤销注册,不掌握持久数据 | 卸载时补写归属会漏掉从未被卸载的数据 |
| 数据块归属在别处没有权威来源 | 必须在写入时登记,且只能由框架登记 |
| 单元自行上报归属可被伪造 | 归属登记不得交给单元 |
| 频繁装卸是正常行为 | 不能因为一次卸载就删除用户进度 |
因此:卸载只标孤儿,不删数据。存档字段、进度与归属记录都保留;缺失单元的旧存档仍可读取,只提示缺失单元。
这不是「反模式的记账」。运行期依赖已有权威来源(例如 inject 边),无须重复记录;而数据块的归属在别处没有任何记载,必须记录。判据是:只有同一事实在别处已有权威来源时,才禁止再记一份。
写入时登记
框架在写入存档时同步登记归属,记录形状如下:
| 字段 | 含义 |
|---|---|
schema | 归属记录格式版本,当前为 1 |
unitId | 写入单元的稳定 ID |
version | 写入时的单元版本 |
blockId | 数据块编号;按后端、槽位、命名空间区分 |
bytes | 该数据块的 UTF-8 字节数 |
firstWrittenAt | 首次写入时间 |
lastClaimedAt | 最后认领时间 |
cleanable | 可清理性声明,只取 closed 或 public |
规则:
- 由框架执行登记。单元不得自行上报归属,否则可以伪造「系统组件」或把责任推给别人。
- 同一稳定 ID 的不同版本共享同一数据块与归属记录;更新版本不另建数据块,
firstWrittenAt保留。 - 数据块按后端、槽位、命名空间区分;不同后端的归属数据互不混淆。
- 归属登记无法读取或已损坏时,相关操作拒绝执行并保留数据,不做推测性修复。
- 默认 IndexedDB 后端把槽位与归属写入同一事务;事务失败不得广播保存成功。
- 外部存档后端不能与本地归属库共享事务,因此先登记不可清理的记录,再调用写入;写入失败时保留保守记录并报告失败。
实现位置:src/core/save/data.ts 定义记录形状与校验,src/core/save/storage.ts 负责事务写入,src/core/save/manager.ts 在保存、读档时生成与更新归属。
稳定 ID
会写持久数据的单元,其清单必须提供:
| 项 | 要求 |
|---|---|
| 稳定 ID | 跨版本不变,作为身份;形状为小写字母或数字开头、由小写字母、数字、点与连字符组成 |
| 版本号 | 合法 semver,随发布更新 |
| 人类可读名称 | 供清理提示展示,取自受审清单 |
硬规则:
- 禁止以内容 hash 作为身份。否则每次更新都产生新身份,旧数据永远成为孤儿,新数据另写一份。
- 同一稳定 ID 的更新不得导致数据丢失或重复。
- 显示名取自清单,不得由单元在运行时指定;否则单元可伪装成「系统组件」欺骗用户。
- 清单校验见
src/core/kernel/manifest.ts;稳定 ID 与版本也进入存档的单元列表。
状态机
写入并登记 ──────────────> 已认领
已认领 ──单元卸载────────> 孤儿
孤儿 ──装载并实际接触──> 已认领
孤儿 ──用户确认清理────> 已清理
孤儿 ──用户选择保留────> 孤儿(计时继续累积)| 状态 | 判定 | 说明 |
|---|---|---|
| 已认领 | 单元在装载集中;实际读档或写入时刷新最后认领时间 | 装载即转为已认领,未接触数据也会保留归属 |
| 孤儿 | 单元的稳定 ID 不在当前装载集中 | 只改状态,不删数据 |
| 可清理候选 | 孤儿 + 声明为 public + 达到阈值 | 仍需用户确认,不是自动删除 |
| 已清理 | 用户确认后由框架删除 | 等价于「该数据从未存在」 |
两个容易误解的点:
- 计时基准是最后一次认领时刻,不是卸载时刻。频繁装卸的单元每次认领都会刷新计时,不会被误清;装载一次后长期不再出现的单元才会累积进入候选。
- 装载本身不刷新认领时间。装载时状态转为已认领,但只有实际接触数据才刷新
lastClaimedAt;标记本身不删除。这样既不会因「装载后立即卸载」永久丢失归属,也不会把「装载了但从未使用」误判为仍在使用。
当前实现中,save:orphans.list() 依据「单元稳定 ID 是否在装载集内」计算 orphan;lastClaimedAt 在保存与读档时刷新。完整的状态机与阈值判定属于 Phase 4。
boot:settled
| 编号 | 规则 |
|---|---|
| BS1 | 判定顺序:装载全部完成 → 首帧渲染完成 → 触发 boot:settled |
| BS2 | boot:settled 是三项动作的统一时机:GC 提示、热更新检查、云同步拉取 |
| BS3 | 不得阻塞首帧;其后的动作全部异步,不得回压渲染 |
| BS4 | 必须防提醒疲劳;同一批孤儿不得重复追问,用户选「否」后记录「本次跳过」或「N 天内不再提醒」 |
当前代码中没有 boot:settled 实现,也没有启动后扫描孤儿、提示或清理的流程。该时机与 GC 一并列入 Phase 4,见Phase 4–6。
清理提示必须展示什么
| 必填项 | 来源 | 目的 |
|---|---|---|
| 人类可读名称 | 受审清单 | 用户能认出是谁的数据 |
| 稳定 ID | 归属登记 | 同名单元之间可区分 |
| 最后认领时间 | lastClaimedAt | 说明多久没用过 |
| 占用空间 | bytes | 说明能释放多少 |
| 数据块摘要 | 数据块内容摘要 | 说明将删除什么 |
| 候选类别 | 超时候选/空间超限候选 | 说明为何被列出 |
约束:
- 禁止以路径代替来源标识。用户看不懂路径,路径也会泄露内部结构。
- 显示名只能来自受审清单,不能由运行中的单元临时提供。
- 清理前必须给出可勾选清单;不可逆操作必须二次确认。
- 同一批孤儿不得重复弹窗;用户拒绝后要记录跳过策略。
当前的归属记录包含稳定 ID、版本、字节数、首次写入与最后认领时间,但不包含显示名与数据块摘要;提示界面也未实现。因此这些字段需要在 GC 实现时由清单与数据块内容补齐。
清理动作的安全约束
| 编号 | 约束 |
|---|---|
| GC1 | 只对孤儿区内的数据块有处置权;白名单式。信任存储被显式排除。 |
| GC2 | 禁止删除:未标记数据、已认领数据、框架与系统自身数据、核心存档。 |
| GC3 | 删除前必须给出可勾选清单;不可逆操作必须二次确认。 |
| GC4 | 清理只能由框架内置实现执行;任何四类单元都不得自行清理孤儿数据。 |
| GC5 | 本机制不暴露机制插槽;不可逆与安全裁决类组件不开放改写入口。 |
| GC6 | 归属登记损坏或读不到时,一律不清理(fail-closed)。 |
另外:
- 信任存储永不可被 GC 处置,也不受 effect 管理、不参与热重载;相关规则见信任与签名。
- 清理只针对孤儿区,不能借「清理」删除他人仍在使用的数据。
- 当前代码中没有任何删除孤儿数据的路径;
save:orphans服务只提供只读查询,不开放登记或清理方法。
可清理性声明
单元在清单中用 persistence 声明:
| 取值 | 含义 | 典型用途 |
|---|---|---|
none | 不写持久数据 | 纯表现或纯计算单元 |
closed | 永不可清理 | 用户进度、不可重建的数据 |
public | 可清理 | 缓存、可重建的派生数据 |
硬性要求:
- 声明为
public者,必须能承受数据块被删除;清理等价于「该数据从未存在」,单元须按首次运行处理。 - 不满足此要求的数据(例如用户进度)必须声明为
closed。 - 代码单元必须在清单中声明
persistence;未声明时 guard 以GUARD_PERSISTENCE_UNDECLARED拒绝装载。数据单元不要求声明。 - 运行期首次写入时,框架比对实际写入位置与声明;不一致即记录告警并按
closed处理。当前实现中,这一比对覆盖保存用户进度时所有者的可清理性声明。 - 这项一致性校验不能放在装载期:写入尚未发生,装载期不可能判定。
旗标归属另有一条并行规则:包通过 flagNamespaces 声明持有的旗标命名空间;同一命名空间只允许一个已装载单元声明,core/sui 保留。新旗标写入必须有已装载的所有者;缺失单元的旧存档旗标保持原值、可再次保存,但没有所有者时不能写入新值。若所有者为用户进度声明了可清理性,保存时记录告警,并仍按 closed 处理。
用户配置
| 项 | 默认 | 用户可调 | 单元可否改写 |
|---|---|---|---|
| 机制总开关 | 开启 | 是 | 否(closed) |
| 孤儿时长阈值 | 待定 | 是 | 否 |
| 占用空间阈值 | 待定 | 是 | 否 |
| 提示频率 | 待定 | 是 | 否 |
- 关闭后:不再提示、不再清理,但孤儿标记继续记录。
- 关闭的后果必须显式告知:孤儿数据将无限累积。
- 配置本身必须是
closed。若阈值可被单元改写,单元就能阻止自己被清理,或诱导删除他人数据。 - 上述配置项与界面当前均未实现。
平台差异
GC 提示不得使用宿主原生对话框,必须是应用内模态层,由 UI 层呈现,网页与 Tauri 共用同一实现。当前该模态层未实现。
当前实现状态
| 能力 | 状态 | 说明 |
|---|---|---|
| 写入时归属登记 | 已完成 | 保存与读档时由框架写入,按后端/槽位/命名空间分块 |
| 稳定 ID 与版本校验 | 已完成 | 清单与存档两侧均校验 |
| 卸载只标孤儿、不删进度 | 已完成 | 卸载不删除旗标或归属登记 |
save:orphans.list() 只读查询 | 已完成 | 返回归属记录副本并附 orphan 状态 |
save:on-orphan-detected 通知 | 已完成 | 卸载后广播受影响的归属记录 |
| 缺失单元读档提示 | 已完成 | 保留旗标与状态,只提示缺失单元 |
boot:settled 后扫描 | 未实现 | 无触发时机实现 |
| GC 提示与可勾选清单 | 未实现 | Phase 4 |
| 用户配置与阈值 | 未实现 | Phase 4 |
| 删除孤儿数据 | 未实现 | 当前没有任何 GC 删除路径 |
save:on-gc-completed | 未实现 | 契约中仅声明,待 GC 落地 |
| 应用内清理模态层 | 未实现 | 不得改用原生对话框 |