Skip to content

同步协议全景(RB ⇄ Supabase ⇄ RVH)

这篇讲「怎么运作、为什么这么定」。 「做错会出事、每会话都要可见」的条文不在这里 —— 在根 CLAUDE.md §4 与 src-tauri/CLAUDE.md §2。 本文只引用编号、不复述条文:判据是「把这篇删掉,规则是否仍然完整」,答案必须是「是」。

🔒 跨仓引用一律带仓名(写「RB #6d」「RVH #6g」,不写裸 #6d)—— 两仓的 5x/6x 编号是两套独立序列,6x 段有同号不同义。


目录

  1. 边界:这篇管什么
  2. 一分钟全景
  3. 三根柱子
  4. 一轮 sync 的时序
  5. 冲突怎么裁
  6. pull 的分支与守卫
  7. 失效模式族谱 —— 五条不变量之间的关系
  8. 贯穿全篇的一条设计取向:不比较两个跨设备的时钟
  9. 快照文件:唯一不走表的通道
  10. 协议版本与用户切换
  11. 两端形状差异
  12. 想再往下走

1. 边界:这篇管什么

想知道去处
系统怎么运作、五条不变量什么关系、当初为什么这么定本文
条文本身(做错会出事的那句话)CLAUDE.md §4 · src-tauri/CLAUDE.md §2
哪张表同步、哪张不同步(yes/no 速查 + 数据流)database-tables-overview.md §8
逐列映射、Supabase 全列定义、迁移版本史database-schema.md §9 / §10
DDL 本体(触发器 / RLS / 索引)supabase/sql/sync-tables.sql
反复重跑的验证判据与假绿风险verification/sync-consistency.md
某一次改动当时发生了什么(时序记述)cross-end/ 编号总表

规模形状:10 张用户表双向同步,3 张推荐表单向拉取;另有两张刻意不同步reference_words 是纯系统共享表,rss_items 靠孤儿清理)。逐表清单见上表第三行, 本文不复述第二份。


2. 一分钟全景

同步不是「把两个数据库变一样」,而是一台设备把自己攒下的变更推上去、再把别人推上去的变更拉下来。 两个方向各由一个问题驱动:

  • push 问:本地哪些行自上次成功上行以来变过? → 答案只由那一行自己的三列决定 (updated_at / synced_at / deleted_at),不看云端
  • pull 问:云端哪些行自上次成功拉取以来变过? → 答案只由一个服务端游标决定 (server_updated_at > last_sync_at),不看本地

两边都不做全量比对,也都不问对方「你那边现在是什么样」。整套协议的复杂度几乎全部来自 这两个问题各自的答案会在什么情况下变得不准 —— 第 7 节就是这份清单。

删除是这里最难的一件事:一条被删掉的行没法用「它变过」来表达(行都没了,拿什么比对)。 所以删除一律是墓碑deleted_at 打标、行留着),墓碑本身是一次「变更」,照常走 push/pull。 本仓的同步事故有一多半出在墓碑上,原因见第 7 节。


3. 三根柱子

3.1 增量游标 = 服务端时间戳,不是客户端时间戳

云端每张同步表有一列 server_updated_at,由 Postgres 触发器 set_server_updated_at()BEFORE INSERT OR UPDATE 阶段用 clock_timestamp() 强制写入 —— 客户端写不到它。 pull 的过滤条件就是它大于本地水位 last_sync_at

为什么不能用客户端的 updated_at:客户端 updated_at 可以任意超前于同一行真正 落到服务端的时刻 —— 离线编辑、攒一批再推、设备时钟偏移,都会造出这个间隙。 沿用它做游标,落在「客户端 ts 已小于水位、服务端 ts 却大于水位」那段区间里的行会被静默跳过 (2026-05-21 实测 25 条 RVH 词永久 miss)。换成服务端列之后,游标与「行真正可见的先后」是同一把尺子。

这条决定了另一件事:整个协议只有一个时钟是权威的,就是 Postgres 的那个。 两台设备的本地时钟从不被拿来互相比较(见第 8 节)。

3.2 脏判定 = 本地一行自己的三列

push 侧「这行要不要推」的谓词收在唯一一处常量 push.rs::USER_SCOPED_DIRTY: 按当前用户归属过滤 + 三支脏条件(从没同步过 / 改过 / 软删过)。

三个消费方共用它,这不是省代码:

消费方用它做什么口径漂了会怎样
10 个 push_*选出本轮要推的行——
get_sync_status 的待推送计数算 UI 上那个「待同步 N」与 push 不同口径 ⇒ 清不掉的计数(推不走却一直显示,或推走了却不计)
pull 的分支②b(RB #6e)问「push 还会不会送这条墓碑」手抄一份就会漂,而漂了的症状与它要修的缺陷一模一样(静默不收敛)

第三行是最反直觉的一处:pull 需要知道 push 的想法。原因见 §7 家族 B。

3.3 删除 = 墓碑,且墓碑必须能被推出去

软删只把 deleted_at 打上,行留在本地也留在云端。于是:

  • 删除能像普通变更一样跨端传播;
  • 「删过又重新加同一个东西」有了确定语义 —— 复活那一行,而不是新建(RB #7 的精神, 两端写侧都刻意这么做);
  • 父行删除时,子树的墓碑得自己在同一个事务里写下来:FK CASCADE 会物理抹掉子行, 连带抹掉本该上行的那条子行墓碑,于是「删了页,词关联却在对端永生」(条文 RB #6a)。

墓碑的代价是它引入了一批只在墓碑上成立的状态组合,第 6、7 节全在讲这个。


4. 一轮 sync 的时序

触发源只有一个:登录后 useAuthStore 起的 60 秒 polling(外加用户手点)。 一轮 sync_now 顺序如下,push 全部做完才开始 pull

0. 续命      access_token 5 分钟内到期就先换新(失败只 warn,让后续 401 冒泡)
1. 协议自检   sync_protocol_version 与常量不符 → 清空 last_sync_at(触发一次全量,见 §10)
2. push      按依赖序 11 步:
             reading_notes → 快照上传 → reading_pages → page_annotations
             → learning_entries → word_page_links → word_cloze_contexts
             → known_words → favorite_sites → rss_feeds → domain_prefs
             每步:SELECT … WHERE <USER_SCOPED_DIRTY> LIMIT 100
                   → POST(Prefer: resolution=merge-duplicates)
                   → 成功后 mark_synced 写回 synced_at
3. pull      同一顺序 10 步(快照不在其中,它是按需取回的,见 §9)
             每步:GET ?user_id=eq.<uid>&order=server_updated_at.asc&limit=100
                       &server_updated_at=gt.<游标>   ← 分页 drain 到取空为止
                   → 逐行走三分支(§6)
                   → 回报本表实际见过的 MAX(server_updated_at)
4. 收尾      last_sync_attempt_at 无条件写(UI 的「上次同步」= 动作时间)
             last_sync_at 只在「本轮零错误」且「真的见到新行」时推进到那个全局 MAX
             emit 'sync-completed' 给主 webview,各面板自行拉新

几处顺序不是随便排的

  • 快照上传排在 reading_pages 之前 —— 上传会回写 storage_path,排在后面的话这一轮推的还是空值, 云端要等下一轮才知道文件在哪。
  • page_annotations 排在 reading_pages 之后 —— 它按 reading_page_id 挂在页上, pull 侧有父行守卫,父行没先落地就会被跳过。pull 顺序与 push 保持一致是同一个理由。
  • 分页要 drain 到取空,不能一表只取 100 行就走。水位是全局一个:A 表被 100 行截断、 B 表却把全局水位推过了 A 最后那行的时刻,A 剩下的行下轮就再也够不着(这是 §7 家族 C 的一个入口)。

两个时间戳别混last_sync_at 是 pull 游标(严格按上面的条件推进), last_sync_attempt_at 是「你上次点同步是什么时候」(每轮无条件写,失败也写)。 UI 显示的是后者 —— 否则「刚同步完但云端没新东西」会显示成「33 分钟前」。


5. 冲突怎么裁

默认是 last-write-wins:push 走 upsert(Prefer: resolution=merge-duplicates),后到的覆盖先到的。 pull 侧对「两端都活着」的行同样以远端为准。这个默认只在三处被刻意推翻:

场景裁法为什么
SM-2 学习状态学习进展更远的一方:复习次数更多 / 下次复习日期更远 / 掌握度更高,三选一命中即取远端(common.rs::should_take_remote_srs单纯 last-write-wins 会让「另一台设备上更早的一次同步」把已推进的复习状态回退。这是唯一一处按业务语义而非时间裁的地方
reading_pages.last_opened_atMAX两端各自 touch 之后双向 sync 不能丢掉更新的那个值
word_cloze_contexts(一词多语境池)双活行的 sentence / surface 定行后不可变,唯独 sense_gloss 取非空一方回填;pull 完对本批每个词跑一次 reconcile_cloze_pool 重新封顶这张表两端共写,池子按 word 单键组织、应用层封顶 5。不重新封顶的话,两端各 5 条 merge 完就是 10 条

冲突约束为什么不是主键

六张表在 upsert 时显式指定了业务唯一键作为 on_conflictlearning_entriesknown_words(user_id, word)word_page_links 用两个 FK、 word_cloze_contexts(user_id, word, sentence)favorite_sites(user_id, domain)rss_feeds(user_id, url))。

理由是两端各自生成 id:同一个逻辑行(同一个用户的同一个词)在两台设备上是两个不同的 UUID。 按主键解冲突时 PostgREST 认为它们是两行,merge-duplicates 不触发,撞上业务唯一约束就是 23505。 指定业务键之后,「同一个词」在云端才收敛成一行。


6. pull 的分支与守卫

每个 pull_* 拿到一条远端行,先按本地存在性 × 两侧墓碑态分三支。这个骨架 10 张表逐字一致:

① remote 已删 + 本地有        → 本地落墓碑(并按需连带软删本地子树,防对端漏传)
② 本地已删 + remote 活        → 见下(RB #6e 把它拆成了两支)
③ remote 已删 + 本地没有      → skip,不落墓碑
   (本地压根没有的东西不需要墓碑;子表/展示由各自的墓碑收拾)
④ remote 活 + 本地活/缺失     → 正常 merge upsert,走 §5 的裁法

分支② 曾是「无条件 skip」,那句注释的前提塌了

原来的写法是「本地墓碑靠 push 传播,不复活」。这句话默认墓碑还是脏的(push 迟早会送它)。 但按写侧规则与 push 成功后的 mark_synced墓碑推完就恒不脏 —— 于是 pull 指望 push、 push 说没得推,两端各说各话地永久停住:一台设备上有、另一台上永远没有,无异常、无告警, 用户做任何操作都修不好(对已墓碑行再删是 no-op)。

现在分支② 拆成:墓碑仍待推 → 照旧 skip(等 push);墓碑已不待推 → 接受远端、复活本地行。 判据由 USER_SCOPED_DIRTY 拼出(§3.2 第三行的那处耦合)。条文与两端落地见 RB #6e。

「远端赢」的决定性理由是可恢复性不对称,不是「远端更可信」: 判错了(对端推的是陈旧活行)用户再删一次就收敛;而分歧态下任何操作都修不了

父行守卫必须是三态,不能压成两态

word_page_links / page_annotations / reading_pages 这类子行落地前要问父行在不在。 答案有三种:活 / 墓碑 / 缺失,收在 pull/mod.rs::parent_state 一处。 两种压缩方向各有各的事故,方向相反:

  • 把「墓碑」并进「活」(裸 COUNT(*))→ 在已删父行下重建活子行 = UI 里隐身的孤儿 (所有查询都带 deleted_at IS NULL,看不见,也没有巡检会说话);
  • 把「墓碑」并进「缺失」(AND deleted_at IS NULL)→ 父行只是还没拉到的子行被 skip, 而水位只按取回的行推进 ⇒ 那行永久丢失,不是「下轮再试」。

条文见 RB #6c。

词类表多两件事

pull/vocab.rslearning_entries / word_cloze_contexts / known_words)是唯一要做 词形归一(RB #9,跨端 PK 一致性)与词典回填的一块:远端推来一个本地词库里没有的词时, 先查 Supabase 的 vocabulary 缓冲池,再没有就走 Edge Function 兜底(顺带把它晋升进缓冲池), 最终仍补不到就 skip 并 warn —— 落一条查不到释义的学习条目没有意义。 缓冲池与预装库的分工见 CLAUDE.md §5。


7. 失效模式族谱 —— 五条不变量之间的关系

这一节是本文存在的理由。 单看每条不变量都像一条孤立的技术规定; 放在一起看,它们是四个失效家族的解药,而且其中两个家族互为因果

条文一律不在这里复述,只给编号与落点。

家族 A:回声环 —— 行永远脏

机制:push 问「synced_at 是不是落后于 updated_at / deleted_at」。 任何让 synced_at 写完那一刻仍然落后的路径,都会让这一行每轮 sync 被重推。

两个独立入口,堵一个不够

  1. 对端软删时不 bump updated_at —— 拉回来的墓碑 deleted_at 是新的、updated_at 停在删除前的旧值;
  2. 两端时钟差 —— 本端拿自己的 nowsynced_at 时,只要本端时钟慢于对端、 且慢过「对端删除 → 本端 sync 起始」这个真实间隔就会触发。autoSync 60 秒意味着这个间隔常只有几秒, 半分钟量级的漂移就够(RVH 真机反算的门槛是 27 秒)。飞行模式、手动改时间、NTP 没跟上都能造出来。

症状是静默灼烧,不是报错:2026-08-03 实测 57 行每 60 秒重推、server_updated_at 被自己不断刷新、 对端每轮重新拉一遍,永不收敛。

挡它的:RB #6d(四条子规则,正文在 CLAUDE.md §4)。 它的四条不是四种写法,是四个不同的入口:写侧一条、pull 侧两条、push 侧一条 —— 少任何一条,同一个环换个身份复发。

家族 B:永久停住 —— 由家族 A 的解药引入

这是最值得记住的一条因果链

#6d 规则 W(软删时 deleted_at 与 updated_at 绑同一个参数)
  + push 成功后的 mark_synced

墓碑推完就恒不脏          ← 家族 A 被解决

分支② 那句「靠 push 传播」的注释,前提悄悄失效

pull 指望 push、push 说没得推 → 永久停住   ← 家族 B 诞生

两端代码一字不差、都没做错,缺口在一句注释的前提没人校验。 所以修法不是「谁让一步」,而是把那个前提变成运行时问出来的问题, 并且让「问的问题」与「push 会做的事」共用同一份常量 —— 结构上不可能漂。

挡它的:RB #6e。也正是它把 §3.2 那条「pull 需要知道 push 的想法」的耦合固定下来。

家族 C:永久丢行 —— 水位不回头

机制:水位只按实际取回的行推进;被跳过的行下一轮 server_updated_at > last_sync_at 再也够不着。协议里没有「下轮再说」这回事 —— 任何形式的「这轮先跳过」都等于永久丢弃。

这条支配了好几个看起来无关的设计:

表现归属
父行守卫不能把「墓碑」并进「缺失」RB #6c
本轮有任何错误就不推进水位(宁可重拉一段)RB #5a
水位推进到本次实际返回行的 MAX,不是「开始同步的时刻」RB #5d
分页要 drain 到取空(全局水位 + 单表截断 = 同一个坑)§4
增量游标用服务端列而非客户端列RB #5 / §3.1

还有一个变体是**「墓碑早于消费能力」:本端补上某张表的 deleted_at之前**, 对端已经写下的墓碑,其 server_updated_at 早已落在本端水位后方 ⇒ 增量 pull 永远够不着, 那些删除会永久停在云端。唯一出路是一次全量 pull,见 §10。

家族 D:跨用户泄漏

机制:push 的 payload 一律写当前登录用户user_id。脏检查若不按归属过滤, 任何遗留在本地表里、不属于当前用户的未同步行,都会被以当前身份 upsert 到云端 —— 无任何报错。 本地 10 张同步表的 user_id 全部可空,唯一屏障是「所有写入都走 current_user_id」这条 运行期约定(不是数据库约束),加个游客模式或某条写入路径漏填就破。

这个家族有一个专属的假绿陷阱:谓词里外层括号漏掉时,ANDOR 结合更紧, 「改过」「软删」两支会完全绕开归属过滤 —— 比不加过滤还糟。而且只测新增行抓不到 (新增行走的正是被括进去的那一支;RVH 实测注入该缺陷后 13 个新增行用例全绿)。 回归测试得造「已同步过、之后又被改/被软删的外来行」。抽成常量正是为了让每个调用点物理上不可能漏括号。

挡它的:RB #5i。它的作用域比「push 的 10 张表」更宽 —— 快照上传(把页面正文字节传到 当前用户的 Storage 前缀下)与 UI 的待推送计数同样在内。

一张速查

失效家族症状(全是静默的)归属编号条文所在
A 回声环每轮重推、永不收敛RB #6d ↔ RVH #6gCLAUDE.md §4
B 永久停住一端有、另一端永远没有,用户修不了RB #6e ↔ RVH #6i同上
C 永久丢行某些行再也拉不到RB #5 / #5a / #5d / #6csrc-tauri/CLAUDE.md §2
D 跨用户泄漏别人的数据出现在你的云端RB #5i ↔ RVH #5hCLAUDE.md §4
墓碑传不出去删除在对端复活RB #6 / #6a / #6bsrc-tauri/CLAUDE.md §2
跨端主键漂移同一个词在两端算两个词RB #9CLAUDE.md §4

8. 贯穿全篇的一条设计取向:不比较两个跨设备的时钟

把上面各节的判据摆在一起,会看到同一条取向反复出现:

地方表面上像在比时间实际比的是
pull 游标「云端哪些行更新」单一权威时钟(Postgres 触发器)产生的序
push 脏判定「这行变没变」同一行自己的三列,全程不涉及第二台设备
墓碑 synced_at 取 MAX「哪个时刻更晚」让脏谓词为假 —— 两个操作数就是这一行将要存下的那两个字符串,用字符串序比
分支②(RB #6e)「谁的时间戳更晚」push 还会不会送它 —— 因果判据,不是时序判据

唯一一处真的在比大小的是 SM-2 merge 的 next_review_date(§5 第一行), 而它比的是业务语义上谁的学习进展更远,不是「谁的时钟更晚」。

这条取向的实践后果有两个,都容易被「顺手优化」破坏:

  • 两端时间戳的字面格式不需要统一(RB 写 +00:00、RVH 写 Z)。 只要每次比较的两个串来自同一个写入方,字符串序就够用。
  • 反过来,把串 parse 成日期再比是危险的:同一微秒时 Z 恒大于 +00:00, 两端小数位位数不同也能让顺序反过来。看起来更严谨的写法在这里更容易错。

9. 快照文件:唯一不走表的通道

页面正文不进数据库 —— 落在磁盘上,并镜像一份到 Supabase Storage 的 reading-snapshots 桶。 它是同步矩阵之外的第 11 条通道,而且两个方向的触发方式刻意不对称

上传(sync/push.rs::upload_snapshots,每轮 sync 都做,每轮至多 20 个)
  reading_pages WHERE 本地有文件 AND storage_path 还是空
  → PUT 字节到 reading-snapshots/{user_id}/{rel}
  → 回写 storage_path(所以这一步排在 push_reading_pages 之前,见 §4)

取回(commands/snapshot.rs::ensure_snapshot_local,按需,不在 sync 里)
  content webview 导航到 rb-cache://localhost/{rel}
  → 文件在盘上? → 直接返回(快路径,零网络)
  → 不在?        → 按 storage_path 反查行(user_id + deleted_at 双闸)
                   → GET Storage → 写盘 → 回填 cached_file_path
                   → 失败则渲染降级页(区分 连不上 / 云端已删 / 从未上传)

为什么取回是按需的:正文体量与行完全不是一个量级,换设备时全量下载既慢又多半用不上。 代价是「行到了、文件还没到」这个中间态必须是可用状态,而不是 404 —— 这正是降级页存在的理由。

这条通道曾经只有上传没有下载(2026-08-06 补齐)。后果:换设备或重装后行全在 (笔记本里列着来源页、标题、词数、存过的词),磁盘上却没有文件,点开 404。 网页快照丢了还能回原 URL 重抓,粘贴文本是唯一副本 = 永久丢失

三层路径约定(改任一处都要三处同时想到):

形态含 user_id
reading_pages.cached_file_path / rb-cache:// URLtext/{id}.html
磁盘{library}/{user_id}/text/{id}.html
Storagereading-snapshots/{user_id}/text/{id}.html

user_id 只出现在磁盘解析与 Storage 前缀两处,不进 DB 值、不进 URL。 这一条约定同时买到三样东西:Storage 前缀不会双重拼接、存量 DB 行零迁移、 同步字段 source_ref(粘贴文档的 source_ref 就是那个 rb-cache:// URL)不用动也不泄漏 user_id

归属提示:这三层路径的权威处是代码本身 —— src-tauri/src/commands/snapshot.rs 的模块头注释。 上表是它的解释性摘要;两者矛盾时以代码为准,回来改这里。


10. 协议版本与用户切换

SYNC_PROTOCOL_VERSION:让一次全量 pull 可以被触发

本地 settings 里存着一个协议版本号。它与代码里的常量不符时,客户端会清空 last_sync_at, 于是下一轮 pull 从头拉一遍;随后写回版本号,幂等。当前是 v3,两次 bump 的理由都属于 §7 家族 C:

版本为什么必须全量一次
v2游标从客户端 updated_at 换成服务端 server_updated_at。旧水位可能任意超前,沿用会静默漏掉落在两者之间的行
v3reading_notes 补墓碑列。补列之前对端写下的笔记墓碑,其 server_updated_at 已落在本端水位后方(实测本机水位 12:35、两条 RVH 墓碑 04:53 / 05:22,差 7 小时),增量 pull 永远够不着

v3 的理由是可推广的:任何一张同步表新增墓碑列,都属于同一个形状 —— 补列前对端已删的行会在本地永久复活。

用户切换:登录时比对,不是登出时清

登出保留 auth_user_id,下次登录拿新 id 与它比对:同一个用户就正常同步, 不同用户(或本地压根没有记录)就清掉本地用户数据。

为什么放在登录侧:登出时清会让「同一个人登出再登入」白白丢一次本地未上行的数据; 而且登出本身可能是被动的(token 失效)。放在登录侧,判据是「进来的是谁」而不是「出去的是谁」。

⚠️ 两端的清理函数语义相反 —— RB 传的是到来的用户,RVH 传的是离开的用户。 互抄那句 user_id 条件会正好删反。细节在 RVH 侧那条红线的正文里(见 §11)。


11. 两端形状差异

协议是一份,两端的落地形状不同,红线编号也是两套

RB(桌面端)RVH(移动端)
语言 / 位置Rust,src-tauri/src/commands/sync/(拆成 push / pull/{vocab,library,prefs} / commonDart,rvh/lib/features/sync/
同步表10 张6 张(与 RB 共享的那 6 张)
脏判定常量push.rs::USER_SCOPED_DIRTY_kUserScopedDirty
编号RB #5i / #6d / #6e …RVH #5h / #6g / #6i …
契约红线正文CLAUDE.md §4rvh/lib/features/sync/CLAUDE.mdrvh/CLAUDE.md 只留索引 + 对照表)

编号为什么不统一:RVH 仓内约 350 处按它自己的编号引用(注释 / 冻结的 handoff / CHANGELOG / SQL), 重编号等于在明令禁改的历史记述里造死链,而且没有任何机械守卫能验「这个裸 #6d 指的是哪个仓」。 所以判据是消歧而不重编号:跨仓引用带仓名,两侧各自维护指向对方的指针。

改哪边:改规则(两端同时受影响)→ 改 CLAUDE.md §4; 改某一端的落地→ 改那一端的文件;改解释(本文这类)→ 改这里。


12. 想再往下走

想做的事去处
改同步表结构CLAUDE.md §9「Schema 同步协议」的五步 + 共享表变更检查清单
验一遍两端有没有漂bash scripts/cross-end-check.sh(§E 以 Supabase DDL 为仲裁者比两端列集合)
跑同步专题的判断层清单verification/sync-consistency.md(先跑 cargo test --lib sync && ./scripts/sync-verify.sh
生产上正在出事/db-incident skill(诊断完成前禁写)+ database-operations.md
查某一次改动当时的原委cross-end/ 的编号总表 —— 那是时序记述,不是当前状态

⏱️ 本文的新鲜度sourceRefs 列在文件头 frontmatter 里。 那几个路径的最新 commit 与 verifiedAt 不同时,说明代码动过而本文未复核。