Skip to content

专题验证 · 同步与多端一致性

常驻清单,不归档。判断层专用——机械层先跑完再看这里。 对象:src-tauri/src/commands/sync/{mod,common,pull,push}.rs + Supabase 10 张同步表 + RVH 镜像 体裁与纪律见 README.md

bash
cargo test --lib sync && ./scripts/sync-verify.sh

0. 这个域为什么危险

它的全部失效都是静默的。 同步不像崩溃——出事时程序照常运行、UI 照常刷新、 get_sync_status 照常显示「0 待同步」,只是数据悄悄不一致了。用户能察觉的时候, 往往已经过了很多轮 sync。

历史上抓到的每一个缺口都是这个形状:

缺口表现抓到的方式
reading_notes 无墓碑列删掉的笔记本在对端永生、下轮复活cross-end/19 人工排查
push 脏检查漏 user_id跨用户数据泄漏,无报错cross-end/21 评审
漏外层括号比不加过滤更糟;只测新增行抓不到(13 个用例全绿)注入式反向验证
墓碑 synced_at 没取 MAX57 行每 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.shRB↔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)

#问句假绿风险
S4last_sync_at 是否只在 errors.is_empty() 时推进?🔴 transient error 时推进 = watermark 跨过没拉到的旧数据,永久丢失且无任何痕迹
S5推进到的值,是本次实际返回行的 MAX(server_updated_at),还是 now() / 请求发起时刻?用时刻做水位 = 漏掉「请求期间 commit 的行」
S6空 batch 时水位保持原值吗?空 batch 推进水位 = 直接跳过一段区间
S7fetch_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 合并策略(三端行为契约)

#问句假绿风险
S11last_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。本仓无法验证 RVH 侧是否已改 —— 不必去 RVH 会话,本仓就能验:拿该行的 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_contextspositions 两个数组的长度 —— 不必揭晓卡去读 n/N也不需要任何会写库的动作(不碰 SM-2)。⚠️ 代价不止「一个空坑」:坏行入池会把最旧那条好语境挤成墓碑、墓碑照常上行(红线 #6b)⇒ 那条真实语境两端都没了。2026-08 已实测复现
S14SM-2 的 get_quality(last_quality, is_easy) 映射表三端仍然一致吗?改一端不改另两端 = 同一张卡在两端算出不同的下次复习日

2.5 快照文件(正文不进 DB)

#问句假绿风险
S15上传每轮做、取回按需——rb-cache://三态回落都在吗(命中 / 取回 / 降级页)?少任何一态 = 「换设备后正文永久丢失」
S16upload_snapshots 的选行也按 user_id 过滤吗?会把别人的字节传到当前用户的 Storage 前缀

2.6 跨仓(RVH)

#问句
S17改过共享表的话,supabase/sql/sync-tables.sql + database-schema.md §9 + cross-end handoff 三处都跟了吗?
S18RVH 侧的镜像动作是不是另开会话做的?(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 🔴 双端真机(机械层永远够不到的那一半)

以下只能人工在两台设备上做,且是这个专题唯一能验证「端到端真的通」的办法

  1. A 端加一个词 → 等一轮 sync → B 端看得到吗?
  2. B 端删掉它 → A 端会不会复活?(墓碑传播)
  3. A 端离线改、B 端在线改同一行 → 上线后合并结果符合 §2.4 的判据吗?
  4. 连续跑 3 轮 sync,server_updated_at 有没有被反复刷新?(墓碑回声,红线 #6d)
  5. 登出 → 换账号登入 → 有没有看到上一个账号的任何东西?(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_contextsvocabulary/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 报得出来
K2push_domain_prefs 是 10 张里唯一不走 post_rows 的,手搓了整个 POST当前行为正确(复合主键、无 on_conflict 参数也能 upsert)。代价是 post_rows 将来任何改动(加 header、改错误映射、改批大小)都会静默漏过这张表下次改 post_rows 时。或它第三次出问题时
K3本地 10 张表的 user_id 全部可空,唯一屏障是运行期约定远端侧已有 N5 断言兜底;本地侧无加游客模式时(那会直接引入 NULL 行),或某次事故追查到 NULL 行时
K4known_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-260.1.0-dev.10Rust 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 → 不成回声环本目录