主题
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-3 | 6 处分支② 逐字同形(见下方代码块);②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_at(P3); WHERE 带 user_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 · _pushKnownWords(4 份) | 行为等价(裁决单那句判断本身对,只是数错了是哪几份、有几份) |
| 完整形状 | _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 是直接单测 helper(redline_6e_tests 调 resolve_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 都看不见)。
本轮不改,两条理由:
- 常见路径上够不到:pull 顺序是
learning_entries→word_page_links,父词条本轮更早 就被同一条红线复活了 ⇒ 走到子行时父行已经是活的。 - 两端次序一致比本端局部更优先。把守卫提到分支② 之前是个严格改进(墓碑父下不复活子行), 但那是两端一起改的事 —— 单方面改就是裁决单 §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 |
| T6 | cloze 满池 + ②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_pages) | T2(reading_pages) · G2 |
| 多出一处裸复活 | G2 |
ON CONFLICT … DO UPDATE SET deleted_at | G3 |
RVH-0 退回(去掉 known_words 的分支②) | T2(known_words) · T7 · G2 |
去掉 cloze 的 ②b total++ | T8 |
去掉 reading_notes 的 ②b total++ | 全绿 —— 对照组,见 §6.3 第 3 条 |
6.3 🔴 两条「做过才知道」的坑
- RB 裁决单 §10 的第 1 条在本仓复现了。 「复活漏写
synced_at」这个注入, 在「远端 ts 早于本地墓碑」那一档下 T4 照样全绿(updated_at > synced_at恰好为假)。 本轮从一开始就把 T4 写成两个方向各跑一次,实测确认只有 later 那档变红。 可复用判据(照抄 RB 的措辞):一条只在时间戳方向凑巧时才成立的断言等于没有断言。 - T4/T5 的探针必须选
word_page_links。 它的分支② 之后是INSERT OR IGNORE(行已存在 ⇒ 整条 no-op),复活写下的两个值原样留在库里;换成word_cloze_contexts或learning_entries,后续的双活逻辑会覆盖synced_at(分别写syncTs/ 本端now) ⇒ 「复活掺了本端时钟」会被下游那条 UPDATE 洗掉,用例变成假绿。探针选错了,不是守卫瞎了。 - 同一句话在 T8 上又演了一遍,这次是拿对照组证的。 去掉 cloze 的 ②b
total++⇒ T8 红; 去掉reading_notes的 ⇒ 21 例全绿(它下游的ON CONFLICTinsert 顺手把 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=6 → 17:41 pushed=0 → 17:42 pushed=0,一次性收敛 (_markSynced 的 P3 在起作用)。
证据三 —— 存量那 1 行按 §8 选 B 处理完毕。单 id no-op touch(写回它自己的 updated_at 原值, 业务列一字未变,只让 set_server_updated_at() trigger 刷新水位列;行快照先存了 backups/):
server_updated_at2026-08-27T03:10:18→2026-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_links 的 dd052755,已逐条查证:其父词条 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。