Skip to content

36 · RVH→RB 回执:「墓碑父行算不算存在」复核结果

物理仓库位置:~/reading_vocab_helper/docs/cross-end/36-rvh-tombstone-parent-confirmation.md 日期:2026-08-27 · 回 ~/reading-browser/docs/cross-end/34-rb-tombstone-parent-adjudication-handoff.md 本会话只动 RVH 仓,RB 仓一个字节没改。

结论:三件事都查了,两件有真缺陷、已修,一件(读侧 _pullReadingPages)本就合规 但表达是两态、已改成显式三态。另外顺手挖出并修掉了 RB #6d 那个 57 行回声环的原始出处 —— 它一直在 RVH 侧没修,RB 只是在自己那边加了 MAX 兜住。


1. 每条「打墓碑」的路径 × 子行 × 事务 × updated_at

判据按父表看,不按命令名看。RVH 全仓给 learning_entriesdeleted_at 的地方, grep -rn "learning_entries" lib --include="*.dart" | grep "deleted_at = " 只有两处本地写侧 (第三处是 pull 消费对端墓碑),逐条如下。为完整起见把 reading_notes 那棵子树也一并列出。

#路径父表word_page_linksword_cloze_contexts同事务子行 bump updated_at本轮
1notebook_datasource.dart::deleteNotebookEntry(删词,UI 三个入口 deleteEntry / deleteEntriesBatch / clearAll 全部汇流于此)learning_entries完全不带走 → ✅✅ 已带走(v57)❌ 两条裸 await → ✅ 单事务❌ → ✅🔧 已修
2app_database.dart::prunePreinstalledVocabulary(预装库 prune 阶段一,v28)learning_entries✅ 本就合规,可作模板
3reading_note_datasource_impl.dart::deleteBook(删笔记,v57.1 / 红线 #6e)reading_notesreading_pagesword_page_links不适用(见 §1.2)只写 deleted_at → ✅🔧 已修
4sync_repository_impl.dart::_pullReadingNotes 分支①(消费对端删笔记时防御性级联本地子树)同上不适用❌ → ✅🔧 已修
5sync_repository_impl.dart::_pullLearningEntries 分支①(消费对端删词墓碑)不适用不适用不改,见 §1.3

1.1 RB 漏的那条第三路径,RVH 侧不存在 —— 且这是产品语义差异,不是遗漏

RB 的 known_words::add_known_word(标「已认识」)会软删 learning_entries,所以它是墓碑路径。 RVH 的对应路径不碰 learning_entrieslocal_known_words_datasource.dart::addOrReviveUserWord 只 revive 或 INSERT known_words 一张表(markAsKnown 的两个调用点 word_detail_notifier.dart:171 / word_library_provider.dart:262 都只走 addUserKnownWordUseCase)。

RVH 的「已认识」语义是过滤器(影响拍照识词的筛词与推荐),不是「从生词本移除」。 所以它不是墓碑路径 ⇒ 没有子树义务。这条已写进 RVH CLAUDE.md #6f 的「不适用」段并注明: 日后若把「标已认识」改成「同时从生词本移除」,本条立刻适用 —— 那时它就是 RVH 版的第三条路径。

1.2 deleteBook 不带走 word_cloze_contexts既定行为,不是本轮漏的

cloze 池的级联键是 source_url = reading_pages.source_ref,而 RVH 的 OCR 页 source_ref 恒为 NULL、 RVH 采集行的 source_url 也按红线 #5g 强制留 NULL(禁造合成 URL)⇒ NULL 匹配不上任何行。 即「在 RVH 删掉某张扫描页不会带走它贡献的 cloze 行」。这在红线 #5g 里已白纸黑字记为知情取舍 (可清理路径是删词 / 句级删除 / 封顶轮换),本轮不动。

1.3 pull 侧消费对端墓碑时不做级联,是对的

_pullLearningEntries 收到对端的词条墓碑时不去软删本地子行 —— 因为 RB 现在会把 「删词带走的 link 墓碑」自己推上云(doc 34 §5),本端各表 pull 各自消费即可。 _pullReadingNotes 那处级联是额外的防御(注释里写明「万一对端漏传」),保留。


2. 🔴 顺手挖出的:RB #6d 那 57 行回声环,出处一直在 RVH 侧没修

doc 34 没点名这件事,但 RB 红线 #6d 记着「触发条件是对端软删时不 bump updated_at (RVH 删笔记本时对 word_page_links 正是如此:deleted_at=04:53updated_at 仍是 01:15)」。

核实结果:那行代码今天还在reading_note_datasource_impl.dart::deleteBook 步骤 ③), UPDATE word_page_links SET deleted_at = ?,没有 updated_at。同款还有两处: _pullReadingNotes 的级联(本地生成、要上行)、以及本轮新写deleteNotebookEntry 子行 (用户在任务里预警的「同一个坑就在旁边」—— 确认过,没踩)。

RB 侧的 synced_at = MAX(sync_ts, deleted_at) 修复兜住了症状,但源头是 RVH 写侧。 三处全部补上 updated_at bump。这也顺带让 RVH 自己的 retryPendingVocabPrune 里那个 updated_at IS NOT NULL AND synced_at < updated_at 的 links 闸门判得更准。


3. pull 每处父行守卫的现状

RVH 的 pull 只有三个 FK 父行守卫(_pullReadingPages 一个、_pullWordPageLinks 两个):

位置父列可空?改前形状判定改后
_pullReadingPagesreading_notes❌ NOT NULLCOUNT(*) ... AND deleted_at IS NULL压成两态(墓碑并进缺失),但两态动作本就相同(父列 NOT NULL ⇒ 缺失和墓碑都只能 skip)⇒ 行为无缺陷改写成显式三态
_pullWordPageLinkslearning_entries❌ NOT NULLCOUNT(*)🔴 压成两态(墓碑并进活)= doc 34 §3 的事故 ①,会在墓碑词条下重建活 link🔧 已修为三态
_pullWordPageLinksreading_pages❌ NOT NULLCOUNT(*)🔴 同上🔧 已修为三态

关键点:RVH 三个父列全是 NOT NULLassets/sql/01_create_tables.sql),所以没有 RB pull_reading_pages 那种「父列可空 → 照落 + 置 NULL」的选项。落地形状统一是 缺失 → skip,墓碑 → skip 远端活行;远端墓碑行不经过守卫(各 _pullXxx 的墓碑三分支 排在守卫之前),照常落地。两态动作虽相同,代码里仍按 switch 显式分开写并各自注明理由, 好让「哪天父列改可空就该分头处理」一眼可见。

守卫收敛到单点 _parentState(db, sql, id) + 三个 _k*ParentSql 常量(镜像 RB pull/mod.rs::parent_state&'static str 约束),不再手搓。

3.1 关于「不存在则 skip,下个周期再试」这类注释 —— RVH 没有

按 doc 34 §2 的提醒全仓搜过:grep -rn "下个周期\|下一个周期\|下轮再\|next cycle\|retry next" lib零命中。RVH 的守卫注释一直只写「skip」,没有把它说成会重试。 _parentState 的新文档里把 doc 34 §2 那条事实(watermark 不回头,跳过 = 永久丢行, 不是下轮再试)正面写进去了,防止日后有人"好心"给守卫加 AND deleted_at IS NULL 时按错误的心智模型推理。

3.2 word_cloze_contexts 的 pull 没有父行守卫 —— 判为不改

该表无 FK、按 word 关联,理论上会落下「父词条是墓碑而 cloze 是活行」的孤儿。 但RB 侧是同一形状pull/vocab.rs:「本地无 + remote 活 → 直接插入(不做 FK 检查,见函数文档)」), 且红线 #5g 的 reconcile 收敛前提是两端同规则。单方面加守卫会让两端各按不同规则收敛、互相拉扯。 ⇒ 故意不加。若 RB 认为该补,请走契约走双端同轮改,别单端动。


4. 改了什么(三个文件 + CLAUDE.md)

文件改动
lib/features/vocabulary_notebook/data/datasources/notebook_datasource.dartdeleteNotebookEntry 整体包进 db.transaction;补 word_page_links 软删;两样子行都 bump updated_atDateTime.now().toUtc()nowUtcIso()。保留「删不存在的 id 不抛」的宽容语义(deleteEntriesBatch / clearAll 逐条调用它)
lib/features/reading_notes/data/datasources/reading_note_datasource_impl.dartdeleteBook 步骤 ③ 补 updated_at bump(= RB #6d 那 57 行的出处)
lib/features/sync/data/repositories/sync_repository_impl.dart新增文件级 enum _ParentState + _parentState() + 三个 SQL 常量;_pullReadingPages / _pullWordPageLinks 两处调用点改三态;_pullReadingNotes 分支①级联 links 时补 updated_at
CLAUDE.md新增红线 #6f(写侧子树 + 子行 bump + 读侧三态,含 CI grep);修正一条已失效的 #6e CI grep(守卫改形后原 grep 恒假红);头部补两仓红线编号对照表

4.1 ⚠️ 红线编号两仓已分叉 —— 请 RB 侧知悉

RVH CLAUDE.md 原写「红线编号沿用 RB CLAUDE.md §4 命名(RVH 镜像版)」,这句今天已经不成立

主题RVH 编号RB 编号
push dirty-check 按 user_id 过滤#5h#5i
push payload deleted_at 传真值写在 #6e 的「反向 footgun 1」#6b
pull 父表禁 INSERT OR REPLACE#6b(无独立条,在 #6 正文)
封面 BLOB 跨端契约#6c(无编号条)
last_opened_at RVH pull-only#6d(在 Sprint C-1 计划里)
reading_notes 软删 + 级联#6e#6a(RB 侧是全表通则)
本单主题#6f#6a / #6c / #6d

RVH 没有把 6c/6d 让出来重编号 —— 那两个号被 RVH 仓多处代码注释和文档交叉引用,重命名的 破坏面大于收益(同 RB README 对「12 重号」的处置)。跨仓引用红线请带仓名或按上表对照, 别只报编号。 5b / 5d / 5e / 5f / 5g 两仓仍一致。

4.2 明确不改的三处(判为不适用,理由见上)

  1. 标「已认识」路径 —— RVH 不碰 learning_entries,非墓碑路径(§1.1)
  2. deleteBook 不带走 cloze —— 级联键 NULL,红线 #5g 既定取舍(§1.2)
  3. cloze pull 无父行守卫 —— 与 RB 同形,单端改会破坏 reconcile 前提(§3.2)
  4. 巡检不重复实现 —— 按 doc 34 §4.3,tombstone_parent 是服务端一份、两端共用,RVH 不做

5. 验证证据

5.1 静态分析

$ dart analyze lib
2737 issues found.          # 全部是既有 info(style lint),
$ dart analyze lib | grep -cE "^\s+error"      → 0
$ dart analyze lib | grep -cE "^\s+(error|warning)" → 0

5.2 新增回归测试 test/features/sync/tombstone_parent_test.dart(10 用例)

按真实 DDL 用 ffi 建库、PRAGMA foreign_keys = ON;写侧驱动真实 datasource 实现, 读侧驱动真实 SyncRepositoryImpl.syncNow()(远端用脚本化假件喂 pull 行),不是转录 SQL。

$ flutter test test/features/sync/tombstone_parent_test.dart
00:04 +10: All tests passed!

覆盖:删词带走 links(且 updated_atdeleted_at 相等)· 带走 cloze · 词条自身墓碑 · 反向断言「别的词的 link / 语境一条都不许动」 · 删不存在的 id 不留半截 · deleteBook 的 links bump(且词条不动)· 父词条是墓碑→活 link 不落地 · 父页是墓碑→不落地 · 反向断言「两个父都活着必须正常落地」 · 父笔记是墓碑→远端活页不落地。

5.3 注入缺陷实证(不是「应该能抓到」,是真跑了)

注入结果
删掉 deleteNotebookEntryword_page_links 软删+9 -1: Some tests failed.
deleteBook 步骤 ③ 去掉 updated_at+9 -1: Some tests failed.
_pullWordPageLinks 守卫退回裸 COUNT(*)+8 -2: Some tests failed.
三处全部还原后复跑+10: All tests passed!

5.4 全量测试 + 既有红线 CI grep

$ flutter test
00:50 +1259 ~5: All tests passed!

既有红线 grep(#5d / #5f / #5g / #5h / #6b / #6e)逐条重跑,除一条外全绿; 那一条是 #6e 的 grep -q "FROM reading_notes WHERE id = ? AND deleted_at IS NULL" —— 守卫改三态后判据挪进 _kNoteParentSql 常量,原 grep 恒假红。已在 CLAUDE.md 里换成新形状并注明为什么换 (留着一条永远报错的 CI grep,等于下一个会话来了先花十分钟排一个假故障)。

新写的 #6f CI grep 也做了注入验证:第一版 grep "SELECT COUNT(*) FROM learning_entries WHERE id = ?" 假红了 —— 它误伤 _pullLearningEntries 里那处合法的「本地行存不存在」检查(决定要不要写墓碑,不是父行守卫)。 改成 sed -n '/_pullWordPageLinks($/,/^ }$/p' 限定函数体后:注入裸 COUNT(*) → 变红,还原 → 变绿。


6. 存量孤儿:RVH 侧不查、按 doc 34 §6 由服务端处置

写侧修复只管今后的删除。存量孤儿(活 link 挂墓碑词条)在云端,两端共享同一份数据, tombstone_parent 巡检已由 RB 部署并在部署当天报出 2 条、清理归零。RVH 侧不重复查、不重复清 —— 重复清理反而有并发写风险。若日后巡检再报,仍按 doc 34 §6 的形状(打墓碑 + bump updated_at, 禁 hard DELETE)由人执行。

跨端并发的残余(一端删词、另一端同时存同一个词)RVH 侧同样不做写侧补丁: doc 34 §4.3 已裁定这是设计残余不是 bug,兜底就是那条巡检。已写进 #6f 正文,防止日后有人 "补全"它而把删词路径搞复杂。


7. 给 RB 的两条反馈

  1. RB #6d 的源头在 RVH,今天才真正堵上(§2)。RB 侧的 MAX(sync_ts, deleted_at)保留 —— 它对老版本 RVH 客户端仍是必要兜底。而且 RVH 的 pull 侧也没有对称的 MAX

    🔧 2026-08-27 订正:本节初稿写「这两处」并把 cloze 列为风险项,两处都不对。 逐条核对 6 个 _pullXxx 的墓碑分支后:5 张表写的是本端时钟 now= syncStartAtsync_repository_impl.dart:152),而 cloze 恰恰是唯一安全的那张

    行号墓碑分支写的 synced_at判定
    reading_notes600now(本端时钟)⚠️ 暴露
    reading_pages705now⚠️ 暴露
    learning_entries837now⚠️ 暴露
    word_page_links1028now⚠️ 暴露
    known_words1180now⚠️ 暴露
    word_cloze_contexts1320syncTs = 远端 updated_at ?? created_at✅ 当前安全

    6 个 push 的 dirty-check 全部deleted_at > synced_at 分支,所以 6 张表都在射程内; 实际暴露的是写 now 的那 5 张。cloze 之所以安全,是因为它把 synced_at远端行取值, 而 RB 的 crud.rs::clear_cloze_context_for 写的是 SET deleted_at = ?3, updated_at = ?3同一个值)⇒ synced_at == deleted_atdeleted_at > synced_at 为假。 ⚠️ 但这是未写进契约的耦合 —— RB 哪天让某条 cloze 软删路径的 updated_at 早于 deleted_at, 这张表立刻跟着炸,且同样无声。

    触发条件(写 now 的 5 张):本端时钟比对端,且慢的量超过「对端删除 → 本端 sync 起始」 之间的真实间隔。autoSync 60s 意味着这个间隔常常只有几秒 ⇒ 几秒以上的时钟漂移就够。 后果与 RB 那 57 行完全同形:该行每轮 sync 被本端重推、server_updated_at 被自己刷新、 对端每轮重新拉一遍,永不收敛,不报错、只静默烧配额

    本轮未改(超出 doc 34 范围,且要改就该两端一起对齐 synced_at 语义)—— 单独立项, 建议 RB 起一份契约单,RVH 这边先记在 backlog。

  2. 红线编号对照表(§4.1)建议 RB 侧 README 或 CLAUDE.md §4 也放一份,两仓各存一份对照, 比谁去改编号都便宜。


8. 待办状态

  • [x] RVH 写侧:删词子树(links + cloze,单事务)
  • [x] RVH 写侧:软删子行一律 bump updated_at(三处)
  • [x] RVH 读侧:三个父行守卫改显式三态,收敛到单点 _parentState
  • [x] RVH 红线 #6f + 编号对照表 + 修正失效的 #6e CI grep
  • [x] 回归测试 + 三处注入缺陷实证
  • [ ] RB 侧登记本文件到 ~/reading-browser/docs/cross-end/README.md 总表(见下)
  • [ ] synced_at >= deleted_at 的 RVH pull 侧对称性(§7.1)—— 待 RB 起契约单

9. 给 RB 会话直接粘贴的 README 总表行

markdown
| 36 | RVH 仓 `docs/cross-end/36-rvh-tombstone-parent-confirmation.md` | RVH 回执 | 「墓碑父行算不算存在」**RVH 侧复核完成**(回 34):写侧查出 `deleteNotebookEntry` **完全不带走 `word_page_links`** 且两条子句根本不在一个事务里 → 已修为单事务三件套;读侧 `_pullWordPageLinks`**两个**父行守卫都是裸 `COUNT(*)`(事故 ①)→ 连同 `_pullReadingPages` 一起收敛到单点 `_parentState` 三态。🔴 **顺手堵上了 RB #6d 那 57 行回声环的原始出处** —— `deleteBook` 软删 link 时不 bump `updated_at` 那行代码今天还在,RB 的 `MAX` 只兜住了症状;RVH 三处已全部补 bump(RB 的 `MAX` 请保留,对老客户端仍必要)。**RB 漏的第三条路径 RVH 不存在且是产品语义差异**:RVH 标「已认识」只写 `known_words`、不碰 `learning_entries`,是过滤器不是删除。⚠️ **两仓红线编号已分叉**(RVH #5h=RB #5i、RVH #6c=封面 BLOB 而 RB #6c=父行三态…),本单 §4.1 给了对照表,跨仓引用请带仓名。RVH 侧新立红线 **#6f**。三处判为不适用并写明理由(标已认识 / `deleteBook` 不带 cloze / cloze pull 无守卫——后者与 RB 同形,单端改会破坏 #5g reconcile 前提)。验证:`dart analyze` 0 error·`flutter test` 1259 passed·新增 `tombstone_parent_test.dart` 10 用例并**三处注入缺陷实证**。另修一条因本改动失效的 #6e CI grep。**文件在 RVH 仓** |