Skip to content

49 ·「本地墓碑 + 云端活行」RVH 侧落地回执

方向RVH 回执(回 47 的 §6.2 / §7)
日期2026-08-31
性质代码已落地,并已装机真机验证(§6.4)。flutter analyze 无 error/warning · flutter test 1402/1402 绿(原 1381 + 本轮 21)
新红线RVH #6i(正文 rvh/lib/features/sync/CLAUDE.md,索引与编号对照表在 rvh/CLAUDE.md)↔ RB #6e(正文根 CLAUDE.md §4)。🔒 跨仓引用带仓名 —— RVH 的 #6e 是 reading_notes 软删级联,别撞
相关红线RVH #5g · #5h · #6f · #6g(W/P1/P2/P3);RB #5i · #6b · #6c · #6d

0. 一句话

裁决单的判据照抄落地,一字未改:分支② 的 skip 现在以「push 还会不会送这条墓碑」为条件, 全程不比较任何跨设备时间戳。实施表 §6.2 本身有三处不成立、两处欠了前置,逐条订正在 §2–§4; 另有一处次序上的相互作用两端都还没处理,登记在 §5。


1. 落地位置(as-built,逐条对 §6.2)

单一文件 rvh/lib/features/sync/data/repositories/sync_repository_impl.dart

#落点与 §6.2 计划的差异
RVH-0_pullKnownWords 补显式分支②(读 localDeletedAt → ②a skip / ②b 复活)与计划一致。先做,理由见 §3
RVH-1常量 _kUserScopedDirty不叫 _kDirtyWhere,见 §4.1);6 个 _pushXxx + getSyncStatus 的 pendingCount 都由它拼出pendingCount 不在 §6.2 的表里,但必须一起改,见 §4.2
RVH-2_tombstonePendingPush(db, table, id)(SQL = SELECT 1 FROM $table WHERE $_kUserScopedDirty AND id = ?)+ _resolveTombstoneVsRemoteAlive + 调用糖 _keepSkippingTombstone合成一个函数(判据 + 复活 + warn),不是 §6.2 的「helper 返回 bool、调用方各写一遍 UPDATE」—— 与 RB 的 as-built(裁决单 §10 RB-1)同形。复活语句因此只有一处,G2 才数得动
RVH-36 处分支② 逐字同形(见下方代码块);②b 命中在 helper 里写一条 AppLogger.w,调用点 total++warn 集中在 helper 一处(不是 6 处),集中比分散更难漏 —— 同 RB 的 RB-3。那句 total++真机补的,见 §4.5
RVH-4新立 RVH #6i#6i 实测确为当时空号,前一条是 #6h);rvh/CLAUDE.md 索引 + 编号对照表双向写明 ↔ RB #6e;RVH #5h 的守卫判据换型,见 §4.3与计划一致

调用点形状(六处逐字同形):

dart
if (localDeletedAt != null) {
  if (await _keepSkippingTombstone(db, '<表>', id, syncTs)) continue;  // ②a
  total++;                                                            // ②b
}

复活语句(一处):

dart
'UPDATE $table SET deleted_at = NULL, updated_at = ?, synced_at = ? '
'WHERE user_id = ? AND id = ?', [syncTs, syncTs, _userId, id]

syncTs = _remoteSyncTs(r)(RVH #6g P1);同条写 synced_atP3); WHEREuser_id = ? ⇒ 不认领归属不明的行,命中 0 行回落 keepSkipping(RB #5i / RVH #5h)。 读状态出错、复活抛错同样回落 keepSkipping —— 沿 _parentState 的「出错就别落地」惯例。


2. 🔴 订正一:§6.2 RVH-1 对「六份副本漂成什么样」的描述不成立,且漏掉一个真缺陷

裁决单原文:「抽的时候会发现 _pushReadingPages 那份写的是 updated_at > synced_at (少了 updated_at IS NOT NULL AND),与另外 5 份不同字 —— SQLite 里 NULL > x 为 NULL/假, 行为等价」。

实测:六份是三种形状,不是「5 + 1」

形状哪几份与统一谓词的关系
updated_at IS NOT NULL AND_pushBooks · _pushReadingPages · _pushLearningEntries · _pushKnownWords4 份行为等价(裁决单那句判断本身对,只是数错了是哪几份、有几份)
完整形状_pushWordClozeContexts就是统一谓词
整支 updated_at 条件都不在_pushWordPageLinkRelations🔴 不等价,见下

🔴 word_page_links 那份是一个真的、今天就在生效的缺陷(裁决单没有察觉,我方也是抽常量 才撞见):该表的脏检查只有 synced_at IS NULL 与墓碑那一支。于是 notebook_datasource.dart::restoreNotebookEntry(删词后 4 秒 SnackBar 的 Undo,RVH #6f 那条 「撤销必须与删除对称」的产物)把 link 复活成 deleted_at = NULL, updated_at = now 之后:

  • 该行不脏 ⇒ 永不上行;
  • 云端与对端那条 link 还是墓碑
  • 而且任何后续用户操作都修不了 —— 复活是幂等的,再撤销一次也不会让它变脏。

症状与本红线要治的那个病同构:本端看得见、对端永远看不见,无报错。 收敛成统一谓词顺带把它堵掉了 —— 与 RB push.rs::USER_SCOPED_DIRTY 对本表的口径也就一致了。

⇒ 对 RB 无动作(RB 本来就是一个常量),但这条值得记进「六份副本会漂」的证据链: 漂的那一份恰好不是裁决单点名的那一份,而且真的漏推过东西。


3. 订正二:§5.1 的触发条件写对了,但 §7.2 只给 T7 提了「push 必须失败」

T1 与 T3 也必须让 push 失败,否则它们测的根本不是 ②a。

_runSync 的顺序是 push → pull。T1(synced_at IS NULL)与 T3(updated_at > synced_at) 的夹具按定义都是脏行:push 会当轮把它们送上去并 _markSynced 成 clean,等 pull 走到分支② 时它们已经不待推了 ⇒ 走的是 ②b,用例断言的「仍是墓碑」必然失败,看上去像实现错了。

本轮的做法:假件加 failPush(抛 Exception_runSync 逐个 push 都包了 try/catch, 只让那张表这一轮没推成)。这同时就是 §5.1 说的第 ② 种真实触发路径 (「一次网络抖动就足以永久吃掉一次删除」),所以夹具不是人造场景。

⇒ 建议 RB 侧核对:RB 的 T1/T3 是直接单测 helperredline_6e_testsresolve_tombstone_vs_remote_alive),不经 sync_now,所以没有这个陷阱;本条只对 「端到端驱动 sync」的写法成立。若 RB 之后补端到端用例,同一个坑还在。


4. 订正三:三处「实施细节按 §6.2 抄会出问题」

4.1 常量名:_kDirtyWhere_kUserScopedDirty

这条谓词自带 user_id = ? 那一半(RVH #5h)。叫 _kDirtyWhere 会让下一个人以为 「还得再加个 user_id 过滤」,那正是 #5h 的括号事故的复发形状。名字必须说出它包含什么。

4.2 getSyncStatus 的 pendingCount 必须同批改(§6.2 的表里没有)

RVH #5h 明写「getSyncStatus 的 pendingCount 口径与之逐表一致」,而它从前是另外六份内联 副本(靠「逐字抄自对应 push 函数」的人肉纪律维持)。§2 那一改让 word_page_links 的 push 口径真的变了 ⇒ 不同批改就当场漂:UI 会显示一个推得走却数不到的行。

本轮把它也改成由 _kUserScopedDirty 拼出,表清单直接取 _syncedTables.keys (= 同步矩阵本身)⇒ 口径漂移与「加第 7 张表漏数」在结构上都不再可能。

4.3 §7.1 G1 的 RVH 形态不能照抄 RB —— 常量与 pull 在同一个文件

RB 的 G1 是「pull_impl_src()synced_at IS NULL 出现 0 次」,成立的前提是 USER_SCOPED_DIRTY 住在 push.rs、不在被扫描的范围内。RVH 是单文件,常量本体自己就含 两处 synced_at IS NULL ⇒ 照抄必然恒红。

落地形态:先把常量声明从源码里切掉,再对剩下的部分断言 0 次(G1b);常量本体形状另立 一条(G1a);引用点计数再立一条(G1c)。三条合起来才等于 RB 那一条。

顺带:RB 记的「剥注释」那个坑在本仓同样命中 —— 判据用了本仓既有的 test/support/source_scan.dart::stripComments(它自己的注释里记着 RB 那次假绿与本仓 statistics_repository_impl.dart 那次假红)。

4.5 🔴 §3.1 第四条「计入该轮 pull 的 count」是硬要求 —— 我第一版漏了,真机当场咬

第一版落地时看 RB 的调用点也没显式计数(continue 或落到下游,由既有逻辑各自计), 就跟着没做。装机验证当场证明这条要求有道理

17:45  Pulled 1 rows → pulled=0 → watermark (no advance)   ← ②b 复活发生在这轮
17:46  Pulled 1 rows → pulled=0 → (no advance)             ← 把同一行重新拉一遍
17:47  Pulled 1 rows → pulled=0 → (no advance)
17:48  pulled=258(行数对账触发的全量自愈)→ 水位终于推进
17:49  Pulled 0 rows → 静止

watermark 的推进闸门是 totalPushed > 0 || totalPulled > 0,而它刻意保守 —— 「pull 返回了、但全被守卫 skip」的行若推进水位就永久丢失_ParentState 文档那条)。 ⇒ 要修的不是闸门,是 ②b 必须承认「我处理了这一行」

⚠️ 下游可能对同一行再计一次(六张表里有四张会)。count 的三个消费者 —— 日志、 上面那个闸门、SyncResult.pulledCount —— 对多计一行都不敏感,而少计会让水位卡住。 两害相权宁可多计,这个取舍写在调用点注释里。

⇒ 建议 RB 侧自查:RB 的 10 个调用点同样是「continue 或落到下游」。若 RB 也有 「pull 返回了行、处理了它、但那条下游路径不计数」的组合(RB 的 cloze 双活分支就是 同一形状),同一个水位卡顿在 RB 侧照样成立。

4.4 §7.1 G2 的 RVH 计数「= 6」不成立

§7.1 写「pull 实现里 deleted_at = NULL 的出现次数 = 10(RB)/ 6(RVH)」。 那是按「调用方各写一遍 UPDATE」算的;改成 RB as-built 的单点 helper 之后, 两端都是 1。RVH 的 G2 落成两条:await _keepSkippingTombstone( 恰好 6(每张同步表一处)

  • deleted_at = NULL 恰好 1。(裁决单 §10 的 RB-1 已经这么做了,只是 §6.2/§7.1 没跟着改。)

5. 🟡 新发现:复活排在父行三态守卫之前(两端同形,本轮不单方面改)

_pullWordPageLinks / _pullReadingPages(RB 对应 pull/library.rs:400 / :257)里, 分支② 排在 RVH #6f / RB #6c 的父行守卫之前。于是存在这条路径:

本地子行是已 clean 的墓碑 + 远端该子行是活的 + 本地父行仍是墓碑 ⇒ ②b 先把子行复活,父行守卫随即把这一轮的落地 skip 掉 ⇒ 本地留下一条挂在墓碑父下的活子行(RVH #6f / RB doc 34 §3b 的服务端巡检 tombstone_parent 会把它报出来,两端 UI 都看不见)。

本轮不改,两条理由:

  1. 常见路径上够不到:pull 顺序是 learning_entriesword_page_links,父词条本轮更早 就被同一条红线复活了 ⇒ 走到子行时父行已经是活的。
  2. 两端次序一致比本端局部更优先。把守卫提到分支② 之前是个严格改进(墓碑父下不复活子行), 但那是两端一起改的事 —— 单方面改就是裁决单 §1 反复强调的那种不对称。

复活出来的那行不会自己扩散(写完即 clean,不上行),所以危害止于「本地一条隐身孤儿」。 请 RB 定:要不要把「分支② 排在父行守卫之后」补进 RB #6e / RVH #6i 的规则文本。


6. 验收

6.1 用例(rvh/test/features/sync/tombstone_remote_alive_test.dart,20 例)

#用例覆盖
T1墓碑 synced_at IS NULL + 远端活 + push 失败 → 仍是墓碑且仍脏②a
T3墓碑推过后又改过(updated_at > synced_at)+ 远端活 + push 失败 → 仍是墓碑②a 的「漏一支」方向
T2 ×6六张同步表逐张:clean 墓碑 + 远端活 → deleted_at IS NULL②b
T4 ×2复活后立刻不脏 —— 两个时间戳方向都跑#6g P3
T5 ×2复活写进去的值逐字 = 远端 updated_at;远端 updated_at 为 NULL 时 = 远端 created_at#6g P1
T6cloze 满池 + ②b 复活第 6 条 → reconcile 后活跃行 = 5,被淘汰行是脏的#5g 相互作用
T7_pullKnownWords:脏墓碑 + 远端活 + push 失败 → 不得synced_at 刷成本端 now§5.1(RVH 独有)
T8②b 复活的那一轮 pulledCount > 0(探针必须是 cloze)§4.5,真机补的
G1a/b/c · G2 · G3 · G4结构守卫,见 §4.3 / §4.4——

6.2 反向注入 —— 13 处(含一处刻意的对照组)

注入红的是
判据恒 false(无条件复活)T1 · T3 · T7
判据恒 true(退回今天的裸 skip)T2×6 · T4×2 · T5×2 · T6
判据窄化成只剩 synced_at IS NULL T3(T1 照绿 —— 与 RB 侧同样的印证:只造新增行抓不到它)+ G1b/G1c
复活漏写 synced_at T4(远端 ts 晚) · T5×2 · G4 —— T4(远端 ts 早) 照绿,见 §6.3
复活掺本端时钟T5×2 · G4
reconcile 封顶差一位T6
pull 侧手抄一份谓词G1b · G2
某张表漏改分支②(reading_pagesT2(reading_pages) · G2
多出一处裸复活G2
ON CONFLICT … DO UPDATE SET deleted_atG3
RVH-0 退回(去掉 known_words 的分支②)T2(known_words) · T7 · G2
去掉 cloze 的 ②b total++T8
去掉 reading_notes 的 ②b total++全绿 —— 对照组,见 §6.3 第 3 条

6.3 🔴 两条「做过才知道」的坑

  1. RB 裁决单 §10 的第 1 条在本仓复现了。 「复活漏写 synced_at」这个注入, 在「远端 ts 早于本地墓碑」那一档下 T4 照样全绿updated_at > synced_at 恰好为假)。 本轮从一开始就把 T4 写成两个方向各跑一次,实测确认只有 later 那档变红。 可复用判据(照抄 RB 的措辞):一条只在时间戳方向凑巧时才成立的断言等于没有断言。
  2. T4/T5 的探针必须选 word_page_links 它的分支② 之后是 INSERT OR IGNORE (行已存在 ⇒ 整条 no-op),复活写下的两个值原样留在库里;换成 word_cloze_contextslearning_entries,后续的双活逻辑会覆盖 synced_at(分别写 syncTs / 本端 now) ⇒ 「复活掺了本端时钟」会被下游那条 UPDATE 洗掉,用例变成假绿。探针选错了,不是守卫瞎了。
  3. 同一句话在 T8 上又演了一遍,这次是拿对照组证的。 去掉 cloze 的 ②b total++ ⇒ T8 红; 去掉 reading_notes 的 ⇒ 21 例全绿(它下游的 ON CONFLICT insert 顺手把 count 补上了)。 六张表里只有 cloze 与 learning_entries 的「本地更胜 / 无 gloss 变化」分支不计数, 探针只能选它们。这条对照组本身也留在注入清单里,防止后人把探针"顺手换成别的表"。

6.4 §7.4 真机验收 —— ✅ 已完成(2026-08-31,Android 24094RAD4C + 真实 Supabase)

装机scripts/run_android.sh,App 启动成功。

证据一 —— §2 那个 word_page_links 缺陷在野外确认存在且被修好:安装前那个进程(旧代码) 连续两轮 pushed=0;新代码第一轮Push user_word_page_links: 6 rows synced。 那 6 行正是 restoreNotebookEntry 复活出来、在旧脏检查下永不上行的存量。

证据二 —— 不是回声环17:40 pushed=617:41 pushed=017:42 pushed=0,一次性收敛 (_markSynced 的 P3 在起作用)。

证据三 —— 存量那 1 行按 §8 选 B 处理完毕。单 id no-op touch(写回它自己的 updated_at 原值, 业务列一字未变,只让 set_server_updated_at() trigger 刷新水位列;行快照先存了 backups/):

  • server_updated_at 2026-08-27T03:10:182026-08-31T17:44:25,越过设备 watermark;
  • 下一轮 pull 返回它 → 日志出现唯一一条 ⚠️ pull word_cloze_contexts: 本地墓碑撞上远端活行且 墓碑已不待推 → 接受远端并复活(RVH #6i ②b)id=3a50ad2b-…
  • 设备上该行落地为 deleted_at = (空)updated_at = synced_at = 2026-08-27T03:10:02.272361+00:00 —— 逐字等于远端行自己的 updated_at(#6g P1),且写完立刻不脏(#6g P3)。 T4 / T5 在真机上各实证一次。
  • ⚠️ 复活前该词池子活跃数是 0(只有这一条墓碑),故不触发 cap-5 淘汰,结局是「两端都有」。

证据四 —— 对账差额归零bash rvh/scripts/sync_reconcile.shword_cloze_contexts 119/120 → 120/120,云端独有 0;learning_entries 100/100 · reading_notes 7/7 · reading_pages 20/20 全平;本地独有全 0 ⇒ push 侧无回归

唯一剩下的差额是 word_page_linksdd052755,已逐条查证:其父词条 04e20dd9在云端也不存在server_updated_at 停在 2026-08-26 —— 这是 RVH #6f / RB doc 34 裁决过的 存量孤儿,pull 侧三态守卫正确地拒绝了它。与本红线无关,属另一条线(清理要由人做、 打成墓碑而非 hard DELETE)。

证据五 —— §4.5 那个水位卡顿,就是这一轮抓到的,已修并补 T8。

⚠️ 一个工具链缺陷挡在验收路上,顺带修了rvh/scripts/sync_reconcile.sh 读的是 SUPABASE_URL,而 admin/.env.local 里的键名是 NEXT_PUBLIC_SUPABASE_URL(同仓 privacy-verify.sh / ops-verify.sh 读的都是后者)⇒ 干净环境下第一次跑就退出; 且那句 : "${VAR:?}" 退的是 1,而 1 在该脚本语义里是「本地独有 > 0 = push 侧回归」, 等于把「凭据没读到」报成一个硬失败结论。两个都修了(前置失败改退 2)。 「判据必须常驻」的脚本自己在干净环境下跑不起来 —— 同一形状。

7. 还没做的

  • §5 那条次序问题:等 RB 表态,两端一起改或一起不改。
  • §4.5 建议 RB 自查:②b 落到「不计数的下游分支」时,RB 侧会不会出现同一个水位卡顿。

存量 1 行与真机验收已完成,见 §6.4。