主题
34 · RB→RVH:「墓碑父行算不算存在」裁定 —— 删除子树 + pull 父行守卫
物理仓库位置:
~/reading-browser/docs/cross-end/34-rb-tombstone-parent-adjudication-handoff.md日期:2026-08-26 · RB 版本0.1.0-dev.10· 对端读法:Read ~/reading-browser/docs/cross-end/34-...md不改 schema、不改同步协议、不动预装库。 变的是两端各自的行为约定: 删词时子行跟不跟着走、pull 落子行前怎么判父行。RVH 若不跟进,两端不会报错, 只会在云端留下对端看不见的孤儿行(正是本单要消灭的东西)。
1. 一句话
RB 此前一致地把「父行是墓碑」当作「父行仍存在」,而红线 #6a / #6c 都是无条件措辞 ——2026-08-26 裁定:写侧判缺陷(子行必须跟着走),读侧判红线措辞错(守卫改三态), 并补上一条巡检,因为这类孤儿在两端 UI 里都不可见、此前没有任何自动检查会说话。
2. 决定性事实(RVH 侧同样成立,值得先看)
watermark 不回头。 拉取水位推进到本轮取回行的 MAX(server_updated_at), 与某行有没有在落地前被守卫 skip 掉无关。所以在「这行能不能插进去」的 FK 守卫上加 AND deleted_at IS NULL,不是「下个周期再试」,而是永久跳过那一行。 RB 的 pull_page_annotations 里就挂着一句写反了的注释(「不存在则 skip,下一个周期再试」), 本轮一并更正。如果 RVH 侧有同款注释或同款守卫,请照此复核。
3. RB 改了什么
| 面 | 改动 | 位置 |
|---|---|---|
| 写侧 | 删词 / 标「已认识」时,同一事务软删 word_page_links + word_cloze_contexts | vocabulary/crud.rs::soft_delete_word_traces,三条路径共用 |
| 读侧 | pull 的父行守卫改为显式三态(活 / 墓碑 / 缺失) | sync/pull/mod.rs::parent_state + 三处调用点 |
| 红线 | #6a 补 join 行归属规则;#6c 整条重写为三态规则 | CLAUDE.md §4 |
| 观测 | 新增 tombstone_parent(warn):活子行挂在墓碑父行下 | supabase/sql/monitoring.sql §3b · scripts/db-audit.sh §3b |
三态的落地形状(RVH 照抄这一段即可):
- 父行缺失 → skip(FK 落地限制;两侧 FK 都 NOT NULL 时没有更好选择)。 父列可空时反而应照落 + 置 NULL(丢页比孤儿贵),RB
pull_reading_pages的note_id是这一形。 - 父行是墓碑 → skip 远端活行;远端墓碑照常落地(防和后续 push 竞速,且落一条墓碑无害)。
- 父行活着 → 正常落地。
裸 COUNT(*) 把「墓碑」并进「活」→ 造孤儿;AND deleted_at IS NULL 把「墓碑」并进「缺失」 → 永久丢行。两种压缩都是缺陷,方向相反。
4. 请 RVH 做的事
- 查自己的删词路径:删一个词(以及任何把
learning_entries打成墓碑的动作, 包括语义上不叫「删除」的,比如标为已认识)时,word_page_links/word_cloze_contexts有没有在同一事务里跟着软删。 ⚠️ 判据按父表看,不按命令名看 —— RB 就是在这里漏了第三条路径。 - 查自己的 pull 父行守卫:是不是三态。两种压缩各自的事故见 §3。
- 别指望写侧根治:跨端并发(一端删词、另一端同时存同一个词)仍会漏下孤儿。 这是设计上的残余,不是 bug ——兜底是 §3 那条巡检,RB 侧已加。
5. 反向影响(RVH 不做也不会坏,但值得知道)
- RB 现在会把「删词带走的 link 墓碑」推上云;RVH pull 到这些墓碑是正常的, 不要当成对端误删。这些 link 的父词在云端同样是墓碑,两端 UI 都不显示。
- RB 的删词语义没有变松或变紧:复活仍是
save_word复活同 id + SM-2 清零 +ON CONFLICT DO UPDATE SET deleted_at = NULL复活软删的 link。 也就是说「删了又加回来,来源还在」这个用户可见行为保持不变。
6. 存量孤儿(部署巡检前先知道这件事)
写侧修复只管新的删除。此前留下的存量孤儿仍在库里,巡检一上线就会报 warn —— 那是真的,不是误报。先只读确认数量:
sql
select count(*) from user_word_page_links l
join user_learning_entries e on e.id = l.learning_entry_id
where l.deleted_at is null and e.deleted_at is not null;清理是写操作,按 docs/database-operations.md 的规矩由人执行、先备份; 形状是把这些 link 打成墓碑(deleted_at = now() + bump updated_at), 不要 hard DELETE(红线 #6)。
7. 待办状态
- [x] RB 写侧 + 读侧 + 红线 + 巡检脚本
- [ ]
supabase/sql/monitoring.sql在 Dashboard 执行(没执行 = 那条兜底不存在) - [ ] RVH 侧按 §4 复核并回执