主题
同步协议全景(RB ⇄ Supabase ⇄ RVH)
这篇讲「怎么运作、为什么这么定」。 「做错会出事、每会话都要可见」的条文不在这里 —— 在根
CLAUDE.md§4 与src-tauri/CLAUDE.md§2。 本文只引用编号、不复述条文:判据是「把这篇删掉,规则是否仍然完整」,答案必须是「是」。🔒 跨仓引用一律带仓名(写「RB #6d」「RVH #6g」,不写裸
#6d)—— 两仓的 5x/6x 编号是两套独立序列,6x 段有同号不同义。
目录
- 边界:这篇管什么
- 一分钟全景
- 三根柱子
- 一轮 sync 的时序
- 冲突怎么裁
- pull 的分支与守卫
- 失效模式族谱 —— 五条不变量之间的关系
- 贯穿全篇的一条设计取向:不比较两个跨设备的时钟
- 快照文件:唯一不走表的通道
- 协议版本与用户切换
- 两端形状差异
- 想再往下走
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_at | 取 MAX | 两端各自 touch 之后双向 sync 不能丢掉更新的那个值 |
word_cloze_contexts(一词多语境池) | 双活行的 sentence / surface 定行后不可变,唯独 sense_gloss 取非空一方回填;pull 完对本批每个词跑一次 reconcile_cloze_pool 重新封顶 | 这张表两端共写,池子按 word 单键组织、应用层封顶 5。不重新封顶的话,两端各 5 条 merge 完就是 10 条 |
冲突约束为什么不是主键
六张表在 upsert 时显式指定了业务唯一键作为 on_conflict (learning_entries 与 known_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.rs(learning_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 被重推。
两个独立入口,堵一个不够:
- 对端软删时不 bump
updated_at—— 拉回来的墓碑deleted_at是新的、updated_at停在删除前的旧值; - 两端时钟差 —— 本端拿自己的
now当synced_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」这条 运行期约定(不是数据库约束),加个游客模式或某条写入路径漏填就破。
这个家族有一个专属的假绿陷阱:谓词里外层括号漏掉时,AND 比 OR 结合更紧, 「改过」「软删」两支会完全绕开归属过滤 —— 比不加过滤还糟。而且只测新增行抓不到 (新增行走的正是被括进去的那一支;RVH 实测注入该缺陷后 13 个新增行用例全绿)。 回归测试得造「已同步过、之后又被改/被软删的外来行」。抽成常量正是为了让每个调用点物理上不可能漏括号。
挡它的:RB #5i。它的作用域比「push 的 10 张表」更宽 —— 快照上传(把页面正文字节传到 当前用户的 Storage 前缀下)与 UI 的待推送计数同样在内。
一张速查
| 失效家族 | 症状(全是静默的) | 归属编号 | 条文所在 |
|---|---|---|---|
| A 回声环 | 每轮重推、永不收敛 | RB #6d ↔ RVH #6g | CLAUDE.md §4 |
| B 永久停住 | 一端有、另一端永远没有,用户修不了 | RB #6e ↔ RVH #6i | 同上 |
| C 永久丢行 | 某些行再也拉不到 | RB #5 / #5a / #5d / #6c | src-tauri/CLAUDE.md §2 |
| D 跨用户泄漏 | 别人的数据出现在你的云端 | RB #5i ↔ RVH #5h | CLAUDE.md §4 |
| 墓碑传不出去 | 删除在对端复活 | RB #6 / #6a / #6b | src-tauri/CLAUDE.md §2 |
| 跨端主键漂移 | 同一个词在两端算两个词 | RB #9 | CLAUDE.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:// URL | text/{id}.html | 否 |
| 磁盘 | {library}/{user_id}/text/{id}.html | 是 |
| Storage | reading-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。旧水位可能任意超前,沿用会静默漏掉落在两者之间的行 |
| v3 | reading_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} / common) | Dart,rvh/lib/features/sync/ |
| 同步表 | 10 张 | 6 张(与 RB 共享的那 6 张) |
| 脏判定常量 | push.rs::USER_SCOPED_DIRTY | _kUserScopedDirty |
| 编号 | RB #5i / #6d / #6e … | RVH #5h / #6g / #6i … |
| 契约红线正文 | CLAUDE.md §4 | rvh/lib/features/sync/CLAUDE.md(rvh/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不同时,说明代码动过而本文未复核。