主题
专题验证 · 同步与多端一致性
常驻清单,不归档。判断层专用——机械层先跑完再看这里。 对象:
src-tauri/src/commands/sync/{mod,common,pull,push}.rs+ Supabase 10 张同步表 + RVH 镜像 体裁与纪律见README.md
bash
cargo test --lib sync && ./scripts/sync-verify.sh0. 这个域为什么危险
它的全部失效都是静默的。 同步不像崩溃——出事时程序照常运行、UI 照常刷新、 get_sync_status 照常显示「0 待同步」,只是数据悄悄不一致了。用户能察觉的时候, 往往已经过了很多轮 sync。
历史上抓到的每一个缺口都是这个形状:
| 缺口 | 表现 | 抓到的方式 |
|---|---|---|
reading_notes 无墓碑列 | 删掉的笔记本在对端永生、下轮复活 | cross-end/19 人工排查 |
push 脏检查漏 user_id | 跨用户数据泄漏,无报错 | cross-end/21 评审 |
| 漏外层括号 | 比不加过滤更糟;只测新增行抓不到(13 个用例全绿) | 注入式反向验证 |
墓碑 synced_at 没取 MAX | 57 行每 60s 重推,永不收敛 | 实测偶然发现 |
| watermark 用客户端 ts | 永久漏掉一段区间的行 | 设计评审 |
所以本清单的第一原则:
🔴 「同步看起来正常」不是证据。 每一格都要问:如果这条链路断了, 会有任何东西变红吗? 没有,那这一格就是靠运气在绿。
1. 机械层已覆盖(不要在判断层重复)
| 层 | 位置 | 守什么 |
|---|---|---|
| 源码 | sync_matrix_tests | 矩阵完备性 · 红线 #5 / #5b / #6b |
| 源码 | push::push_user_scoping_tests | 红线 #5i(含漏括号反向断言 + 结构守卫) |
| 源码 | pull::tombstone_echo_tests | 红线 #6d P2(含「去掉 MAX 就复现」反向断言) |
| 源码 | common::mark_synced_tests | 红线 #6d P3(push 侧 mark 后必须 clean,含反向断言) |
| 源码 | pull::tombstone_synced_at_guard | 红线 #6d W/P1/P2 结构守卫:全仓每条墓碑 UPDATE 二选一落在 (P1+P2) 或 W 上 + pull 站点数 = 10(三条规则各注入一次缺陷实证) |
| 源码 | pull::reconcile_tests | 语境池封顶 + 合并后重新封顶 |
| 源码 | migration_chain_tests | 墓碑列完备性(清单取自 PENDING_PUSH_TABLES) |
| 源码 | pending_push_sql_tests | 待推送计数与 push 同口径 |
| 部署态 | scripts/sync-verify.sh N1-N7 | 远端表/列/trigger/RLS/NULL 行/PGRST204/on_conflict 约束 |
| 跨仓 | scripts/cross-end-check.sh | RB↔RVH 源码结构 diff · 预装库 byte-equal · SM-2 |
这些都做过注入式反向验证(2026-08-26:4 条 Rust 守卫 + N0/N6 各注入一次真实缺陷, 全部变红)。其中 pull_covers_every_synced_table 第一版是空断言—— 用裸 contains("INTO x") 会被 INTO x_TYPO 前缀匹配到,改名后仍全绿。已改为按标识符边界匹配。
2. 判断层逐格判据
2.1 三分支 pull 的语义
| # | 问句 | 假绿风险 |
|---|---|---|
| S1 | 三分支是否都在:远端删→标本地 / 本地删→不复活 / 本地无+远端删→不落墓碑? | 缺第三支会在本地凭空造出墓碑行,且这些行会被 push 推回去 |
| S2 | 「本地删但远端未删」时,确认是 skip 让 push 去传播,而不是被远端活行覆盖? | 覆盖 = 用户的删除被静默撤销 |
| S3 | 父行守卫回答的是三态(活/墓碑/缺失)还是被压成了两态? | 2026-08-26 裁定为红线 #6c 新形(K1 面 B 关闭)。两种压法都要查:裸 COUNT(*) = 墓碑当活行(造孤儿);AND deleted_at IS NULL = 墓碑当缺失(永久丢行,watermark 不回头)。守卫走 pull/mod.rs::parent_state,手搓的一律可疑 |
| S3a | 凡把父行打成墓碑的路径,子行都跟着走了吗? | 判据按父表看,不按命令名看——add_known_word(标「已认识」)也软删 learning_entries,它曾是三条路径里唯一两样子行都不带走的。写侧修完仍有并发窗口漏网,别把 S3a 全绿当作库里没有孤儿:那要看巡检 tombstone_parent |
2.2 水位(watermark)
| # | 问句 | 假绿风险 |
|---|---|---|
| S4 | last_sync_at 是否只在 errors.is_empty() 时推进? | 🔴 transient error 时推进 = watermark 跨过没拉到的旧数据,永久丢失且无任何痕迹 |
| S5 | 推进到的值,是本次实际返回行的 MAX(server_updated_at),还是 now() / 请求发起时刻? | 用时刻做水位 = 漏掉「请求期间 commit 的行」 |
| S6 | 空 batch 时水位保持原值吗? | 空 batch 推进水位 = 直接跳过一段区间 |
| S7 | fetch_remote 内部 drain 完每张表的所有页了吗? | 只拉第一页而水位取全局 MAX = 静默丢中间页 |
2.3 归属与隔离
| # | 问句 | 假绿风险 |
|---|---|---|
| S8 | 登出时是否调了 resetAllTabs() 并把 review 重置到 empty state? | 🔒 下一个用户会看到上一个用户的浏览/复习页面(隐私) |
| S9 | 换用户清理逻辑的 WHERE 里那个 user_id IS NULL,还会误伤 pull 下来的行吗? | 红线 #5b 的级联事故入口 |
| S10 | 本地 10 张表的 user_id 全部可空——当前有没有任何写入路径会留下 NULL? | 唯一屏障是运行期约定不是约束。加游客模式即破。远端侧 N5 已断言,本地侧没有 |
2.4 合并策略(三端行为契约)
| # | 问句 | 假绿风险 |
|---|---|---|
| S11 | last_opened_at 取 MAX 吗?RVH 只 pull 不 push 此列吗? | 取「远端优先」会让双向 sync 反复丢新值 |
| S12 | 语境池合并后有没有重新封顶? | 两端各 5 条 merge 成 10 条 |
| S13 | 入池句能被目标词整词命中吗?两端的 created_at 是同一种 UTC ISO8601 形状吗? | 🔒 两条只对采集侧成立的约束(pull 侧无条件 INSERT、不校验质量)。⚠️ 判据不是「是不是 UTC」而是「两端形状一致」——排序键是 text 列、比的是字典序。RB 写 to_rfc3339()→+00:00(已实测符合);RVH 采集层历史上用裸 DateTime.now()(本地时区、无偏移)→ UTC+8 下恒显晚 8 小时,「留新删旧」退化成「留 RVH 删 RB」,无报错、无不一致可观测。修法与全部推理见 docs/cross-end/23-rb-ocr-cloze-context-decision.md §C3。created_at 与服务端 trigger 写的 server_updated_at 比(红线 #5d,后者是服务端时钟)。偏置会让 created_at 晚于 server_updated_at 约一个时区差;正常则早一个 sync 延迟。⚠️ 不要改用「双端各写一行、看跨端封顶淘汰了谁」来验 —— 那种排序法有时间窗:只在「现在还没走到对端那行自称的时刻」之前有效,越过之后有偏置与无偏置给出同一个观测,是会骗人的绿。2026-08 的跨端 relay 在这条上连栽两次(两次都是把「存的是多少」当成了随假设变化的量 —— 它是观测),详见 cross-end-cloze-pool-2026-08.md §2 3-b/3-c |
| S13a | 池里活跃行数与复习卡 position list 的长度相等吗? | 不等 = 有行占着封顶坑位却在 UI 里不可见(C1 静默占位)。🔑 取证走 useReviewStore:直接比 currentCard.cloze_contexts 与 positions 两个数组的长度 —— 不必揭晓卡去读 n/N,也不需要任何会写库的动作(不碰 SM-2)。⚠️ 代价不止「一个空坑」:坏行入池会把最旧那条好语境挤成墓碑、墓碑照常上行(红线 #6b)⇒ 那条真实语境两端都没了。2026-08 已实测复现 |
| S14 | SM-2 的 get_quality(last_quality, is_easy) 映射表三端仍然一致吗? | 改一端不改另两端 = 同一张卡在两端算出不同的下次复习日 |
2.5 快照文件(正文不进 DB)
| # | 问句 | 假绿风险 |
|---|---|---|
| S15 | 上传每轮做、取回按需——rb-cache:// 的三态回落都在吗(命中 / 取回 / 降级页)? | 少任何一态 = 「换设备后正文永久丢失」 |
| S16 | upload_snapshots 的选行也按 user_id 过滤吗? | 会把别人的字节传到当前用户的 Storage 前缀 |
2.6 跨仓(RVH)
| # | 问句 |
|---|---|
| S17 | 改过共享表的话,supabase/sql/sync-tables.sql + database-schema.md §9 + cross-end handoff 三处都跟了吗? |
| S18 | RVH 侧的镜像动作是不是另开会话做的?(CLAUDE.md §9 会话隔离规则) |
| S19 | 加列时的顺序:先加远端、后发客户端。反过来会让整批 push 报 PGRST204 而静默停摆 |
3. 反向验证配方
3.1 源码守卫(注入 → 必须变红 → 还原)
scripts/(scratch) 里那套注入脚本的形状:备份 → sed 注入一处真实缺陷 → 跑对应测试 → 确认 FAILED → 还原 → git diff --quiet 复核零残留(trap 兜底)。
四种值得常备的注入:某张表在 pull 里改名(矩阵完备性)· INSERT 列清单删掉 user_id(#5b)· ON CONFLICT DO UPDATE SET 混进 deleted_at(#6b)· 水位改用客户端 updated_at(#5)。
3.2 部署态(N6 最值得注入)
往任一 push 载荷塞一个远端没有的键,sync-verify.sh 必须报 PGRST204 风险。 这条模拟的正是「加列时顺序搞反」——本域最常见的静默停摆。
3.3 🔴 双端真机(机械层永远够不到的那一半)
以下只能人工在两台设备上做,且是这个专题唯一能验证「端到端真的通」的办法:
- A 端加一个词 → 等一轮 sync → B 端看得到吗?
- B 端删掉它 → A 端会不会复活?(墓碑传播)
- A 端离线改、B 端在线改同一行 → 上线后合并结果符合 §2.4 的判据吗?
- 连续跑 3 轮 sync,
server_updated_at有没有被反复刷新?(墓碑回声,红线 #6d) - 登出 → 换账号登入 → 有没有看到上一个账号的任何东西?(S8)
⚠️ 开工前先确认库里有没有数据(reset-dev-data 之后会全空)。 表若是空的,上面 5 条一条都做不了,而机械层的绿只覆盖结构不覆盖行为 —— 此时的「全绿」几乎不携带信息量,报告里必须写明这个前提。
2026-08-26 现状:库里有真实同步数据,且第 2、4 条已用真实删除跑通:
| 条目 | 结果 |
|---|---|
| 墓碑传播(本地删 → 远端) | ✅ 5 张表本地与远端墓碑数逐张相等 |
| 墓碑回声环(红线 #6d) | ✅ 全部墓碑行 synced_at >= deleted_at,待推送计数 0 —— 不重推 |
| 删笔记本的子树(红线 #6a) | ✅ note + page + 该页 links 同一时刻软删,子树完整 |
| 删词的子树(红线 #6a) | ❌ 当时不完整(word_page_links 不跟着走)→ 2026-08-26 已修(K1 裁定,soft_delete_word_traces)。遗留的 2 条孤儿已软删清理,tombstone_parent 归零。修复本身的真实数据复验待下一轮:要再删一个有来源的词,确认 links 这次跟着走 |
第 1、3、5 条(跨设备可见、离线合并、换账号隔离)仍需两台设备,未做。
4. 已知未修 / 有意接受
| # | 事情 | 现状 | 什么条件下重新处理 |
|---|---|---|---|
| K1 | ✅ 已裁定并收尾(2026-08-26):「墓碑父行算不算存在」不再是未言明的立场。面 A 判为缺陷 —— 删词/标已认识现在同事务软删 word_page_links + word_cloze_contexts(vocabulary/crud.rs::soft_delete_word_traces,三条路径共用);面 B 判为红线措辞错 —— pull 的父行守卫改为显式三态(parent_state),红线 #6c 随之重写 | 兜底巡检已部署:tombstone_parent 于 2026-08-26 落库,并用库里那 2 条历史孤儿实测报得出来(warn cnt=2)—— 部署即反向验证,「装上了」与「装上了且在工作」一次分清。那 2 条随后已软删清理(红线 #6,非硬删),巡检归零。🔴 残余仍在:跨端并发(一端删词、另一端同时存同一个词)还会造孤儿,写侧根治不了 | ⚠️ 假绿风险未消:pull 侧三态守卫只有 parent_state 本身有单测,三个调用点没有(要网络 + 异步),漏改一处不会红。下一轮:双设备造一次并发窗口,确认 tombstone_parent 报得出来 |
| K2 | push_domain_prefs 是 10 张里唯一不走 post_rows 的,手搓了整个 POST | 当前行为正确(复合主键、无 on_conflict 参数也能 upsert)。代价是 post_rows 将来任何改动(加 header、改错误映射、改批大小)都会静默漏过这张表 | 下次改 post_rows 时。或它第三次出问题时 |
| K3 | 本地 10 张表的 user_id 全部可空,唯一屏障是运行期约定 | 远端侧已有 N5 断言兜底;本地侧无 | 加游客模式时(那会直接引入 NULL 行),或某次事故追查到 NULL 行时 |
| K4 | known_words 的本地↔远端行数天生不等:merge_recommended_into_user_excluded 把 211 条预装停用词灌进 known_words 时预填 synced_at = now,让未被用户触碰的推荐行永不 push(否则每个用户往云端复制 211 行)。用户删/改某条会 bump updated_at > synced_at,那时才推 | 设计如此,不是缺口 | —— 但别把「本地↔远端行数对账」写成机械断言:不排除 source='recommended' AND updated_at <= synced_at 的话它恒红。2026-08-26 实测踩过一次 |
| K5 | 「本地墓碑 + 云端活行」两端永久分歧 —— pull 分支②「本地已删 + remote 活 → skip,靠 push 传播」的前提(墓碑还脏)在墓碑推完后就不成立(#6d 规则 W + mark_synced ⇒ 推完恒不脏)⇒ pull 指望 push、push 说没得推,静默停住、永不收敛。实例 word_cloze_contexts 3a50ad2b(2026-08-31 真机)。⚠️ 不是 cloze 独有:分支② 在 RB 10 个 pull_* 里逐字都在,8 张表有复活写路径 —— learning_entries 上的形状是「手机删词、桌面再存 ⇒ 手机永远没有」 | ✅ 已裁决(cross-end/47),两端均未实施。判据 = 「push 会不会把这条墓碑送出去」(不比任何时间戳)。🔴 顺带查出 RVH _pullKnownWords 没有分支② 且把 synced_at 刷成本端 now ⇒ 已认识词的删除今天就在静默丢 | 实施后本条转「已修」,验收见裁决单 §7。⚠️ 两条假绿风险:① 只测「clean 墓碑 + 远端活 → 复活」会让「无条件复活」的实现全绿 —— 必测「墓碑仍脏 → 仍 skip」这条反向用例;② 存量那 1 行不会自愈(watermark 已越过),实施后跑对账仍会看到差额,别据此判「没修好」——先按 §8 做 no-op touch |
⚠️ 每轮验收后更新本表。一条「已知未修」若其实已被修,会让下一轮跳过一次本该做的检查。
5. 验收台账
| 日期 | 基线 | 机械层 | 判断层结论 | 留痕 |
|---|---|---|---|---|
| 2026-08-26 | 0.1.0-dev.10 | Rust 4 条 + sync-verify.sh 14/14 PASS | 建成本专题。新增 4 条源码守卫(全部注入验证过鉴别力,其中 1 条首版是空断言已修);杀掉 migrations.rs 里并行维护的第二份表清单;新发现 K1(红线与代码公开冲突)+ K2 | 本目录 |
| 2026-08-26(补) | 同上 | 同上 | 库里恢复真实数据后补跑数据依赖项:10/10 表本地↔远端对账一致(含 K4 那条修正)· run_db_audit() 有数据前提下仍 [] · 语境池封顶 5 未被突破 · RB 侧 created_at 形状合规。仍未验:墓碑数为 0 → 删除传播与回声环这两条链路一次都没跑过 | 本目录 |
| 2026-08-26(补二) | 同上 | 同上 | 用户执行真实删除(2 个词 + 1 个笔记本)后补验:墓碑传播 ✅ · 回声环 ✅ 无 · 删笔记本子树 ✅ 完整 · 删词子树 ❌ 不完整(K1 面 A,新发现)。巡检对孤儿沉默已实测确认 | 本目录 |
| 2026-08-26(补三) | 同上 | 同上 | K1 收尾:tombstone_parent 部署 + 实测报出 2 条孤儿 + 清理归零(软删,18 行总数不变)。远端下墓碑走 pull 三分支正路,deleted_at = updated_at 使 synced_at = MAX(...) 后 push 脏检查为 false → 不成回声环 | 本目录 |