主题
38 · RB→RVH 契约单:墓碑行的 synced_at 怎么写(红线 #6d 升格为双端契约)
物理仓库位置:
~/reading-browser/docs/cross-end/38-rb-tombstone-synced-at-contract-handoff.md日期:2026-08-27 · 回~/reading_vocab_helper/docs/cross-end/37-rvh-tombstone-synced-at-handoff.md(承 36 §7.1) 本会话只动 RB 仓,RVH 仓一个字节没改。裁定:doc 37 的核心请求成立 —— 旧条文确实只写了「取 MAX」,照它审 RVH 那 5 处能全部蒙混过关。 本单把 #6d 拆成四条子规则(W / P1 / P2 / P3),并把 doc 37 §7.1 点名的那条 「未写进契约的耦合」(cloze 靠 RB
deleted_at == updated_at同值才安全)正式收编为规则 W。 RB 侧现有的MAX(sync_ts, deleted_at)按请求保留。
1. 契约全文
适用范围 = 同步矩阵那 10 张表的所有软删与
synced_at写入路径,两端同文。
总纲:凡写 synced_at,写完那一刻该行必须立刻不脏。
「脏」的定义两端已经同文(RB push.rs::USER_SCOPED_DIRTY / RVH 6 个 _pushXxx 的 WHERE):
sql
synced_at IS NULL
OR (updated_at IS NOT NULL AND updated_at > synced_at)
OR (deleted_at IS NOT NULL AND (synced_at IS NULL OR deleted_at > synced_at))四条子规则,缺一不可(下面 §1.5 逐条说明为什么不能只留其中几条):
W —— 写侧:软删时 deleted_at 与 updated_at 写同一个值
本端软删同步表任一行时,同一条 UPDATE 里把 updated_at 绑成与 deleted_at同一个参数(不是「也写一下」,是同值)。
sql
UPDATE <表> SET deleted_at = ?1, updated_at = ?1 WHERE ... -- ✅
UPDATE <表> SET deleted_at = ?1 WHERE ... -- ❌ 对端的回声环从这里出生
UPDATE <表> SET deleted_at = ?1, updated_at = ?2 WHERE ... -- ❌ 两个值 = 两个真相这条不是给自己用的,是给对端用的:对端 pull 到这行时唯一能拿来当 synced_at 的素材 就是 updated_at,updated_at < deleted_at 的行一落地就是脏的。 2026-08-03 那 57 行、以及 doc 36 §2 里 deleteBook 那处,都是这条被违反的产物。
🔑 doc 37 §7.1 问的「cloze 为什么安全」,答案就是这条:RB 的
vocabulary/crud.rs::clear_cloze_context_for写deleted_at = ?3, updated_at = ?3。 它此前不是契约、只是恰好。现在是契约了,并且 RB 侧有结构守卫钉着(§3.3)。
P1 —— 读侧:synced_at 只能由该远端行自己的列拼出,禁止本端时钟
pull 墓碑分支写 synced_at 时,允许出现的素材只有该远端行的 updated_at / created_at / deleted_at。任何形式的本端 now 都不许出现 —— 包括 「先算个 now 再和别的取 max」「NULL 时兜底成 now」这类看着无害的写法。
这条是独立于 P2 的,也是 doc 37 §核心请求指出的缺口:RVH 那 5 处压根没写 MAX, 形式上不违反 P2,违反的是 P1。旧条文只说了 P2,所以审不出来。
P2 —— 读侧:与 deleted_at 取最大,且操作数不许为 NULL
synced_at := MAX(写完后的 updated_at, 写完后的 deleted_at)- 「写完后的」是字面意思:MAX 的两个操作数必须就是这一行 UPDATE 结束后实际存着的那两个 字符串。RB 的做法是在同一条 UPDATE 里把
updated_at也写成远端值,于是操作数 = 绑的那两个参数。 若某处不覆盖updated_at(RVH_pullWordPageLinks当前就不写),那 MAX 的第一个操作数 必须是本地既有的那个updated_at,不是远端的 —— 否则脏检查比的和你算的不是同两个值。 推荐直接照 RB 的形状一并覆盖,省掉这一整类推理。 sync_ts = 远端 updated_at ?? 远端 created_at,绝不能是 NULL。理由见 §4.1。
P3 —— 收敛判据覆盖 push 侧的 mark
「写完立刻 clean」对每一条写 synced_at 的路径成立,包括 push 成功之后的 _markSynced / mark_synced,不只是 pull 的墓碑分支。
1.5 为什么四条缺一不可
| 只丢掉 | 会发生什么 | 现实例子 |
|---|---|---|
| W | 对端每次 pull 你的墓碑都会落成脏行 → 对端每轮重推 | RB 2026-08-03 的 57 行 |
| P1 | 本端时钟慢于对端即触发;autoSync 60s 下几秒漂移就够 | RVH 5 张表(doc 37) |
| P2 | 对端违反 W 时你这边立刻中招(你没法假设对端合规) | 同 57 行事件的另一半 |
| P3 | 拉回来的墓碑一旦再次变脏,重推后永久留脏 | RVH _markSynced 写平铺 now |
W 与 P2 是互为兜底的一对:W 保证「我发出去的墓碑不害人」,P2 保证「对端害我时我扛得住」。 两端各自都要有两条,不能靠「反正对端会守 W」省掉 P2:W 是对端写侧的一行代码, 它随时可能因为一次无关重构而改变,而破坏是无声的(doc 36 §2 那三处 bump 就是补上去的, 在此之前 RB 的 P2 是唯一挡着的东西)。这正是 RB 保留 MAX 的理由(doc 37 也是这么请求的)。
顺带说明兼容性口径:两端当前都只有内测用户、可随时重装,所以本契约 不需要为老版本客户端做任何兼容设计,也不需要迁移。保留 P2 的理由与「老客户端」无关, 就是上面那条「对端写侧随时可能变,而破坏无声」。
2. 触发条件与症状(两端同形,供 RVH 复现用)
本端时钟慢于对端,且慢的量超过「对端删除 → 本端 sync 起始」的真实间隔。 autoSync 60s ⇒ 这个间隔常常只有几秒 ⇒ 几秒漂移就够。
后果:该行每轮被本端重推 → 服务端 trigger 刷新 server_updated_at → 对端下轮又拉回来 → 永不收敛。不报错、不产生错数据、只静默烧配额和电量,两端 UI 都看不出任何异常。 RB 2026-08-03 实测规模:57 行 / 60s 一轮。
3. RB 自查结果(doc 37 请求的第 2 项)
3.1 pull 的 10 处墓碑分支:全部合规
逐处核对了三件事 —— 是否写 MAX、MAX 的第一个操作数取自哪里、该操作数是否可能为 NULL:
| # | 表 | 位置 | sync_ts 来源 | 远端 updated_at 可空? | 判定 |
|---|---|---|---|---|---|
| 1 | learning_entries | pull/vocab.rs:123 | &row.updated_at | ❌ NOT NULL | ✅ |
| 2 | word_cloze_contexts | pull/vocab.rs:300 | updated_at ?? created_at | ✅ 可空 | ✅ |
| 3 | known_words | pull/vocab.rs:498 | &row.updated_at | ❌ NOT NULL | ✅ |
| 4 | favorite_sites | pull/prefs.rs:69 | &row.updated_at | ❌ NOT NULL | ✅ |
| 5 | rss_feeds | pull/prefs.rs:175 | &row.updated_at | ❌ NOT NULL | ✅ |
| 6 | domain_prefs | pull/prefs.rs:301 | &row.updated_at | ❌ NOT NULL | ✅ |
| 7 | reading_notes | pull/library.rs:81 | &row.updated_at | ❌ NOT NULL | ✅ |
| 8 | reading_pages | pull/library.rs:235 | updated_at ?? created_at | ✅ 可空 | ✅ |
| 9 | word_page_links | pull/library.rs:370 | updated_at ?? created_at | ✅ 可空 | ✅ |
| 10 | page_annotations | pull/library.rs:484 | &row.updated_at | ❌ NOT NULL | ✅ |
三张可空的(#2 / #8 / #9)恰好就是 supabase/sql/sync-tables.sql 里 updated_at TEXT(无 NOT NULL)的那三张,且这三处都有 ?? created_at 回落, created_at TEXT NOT NULL 在 10 张表上无例外 ⇒ P2 的「不许为 NULL」在 RB 侧成立。 另核:10 处都在同一条 UPDATE 里把 updated_at 写成了与 MAX 第一操作数相同的值 ⇒ 「写完后的值」这条推理不需要额外绕。
3.2 写侧 27 处:全部合规
全仓 SET deleted_at = ?N 的非 pull 站点共 27 处(commands/** + db/word_list.rs), 每一处都在同一条 UPDATE 里写 updated_at = ?N(同一个参数)。 即规则 W 在 RB 侧本就 100% 成立 —— 但此前没有任何东西钉着它,这正是 doc 37 §7.1 的担心。
3.3 新增:结构守卫(把契约变成会红的测试)
src-tauri/src/commands/sync/pull/mod.rs::tombstone_synced_at_guard —— 扫全仓 Rust 源码, 每条「打墓碑」的 UPDATE 二选一落在两条规则上:
- 写
synced_at(= pull 消费远端墓碑)→ 必须synced_at = MAX((P2), 且绑参里不许出现now/Utc::now(P1); - 不写
synced_at(= 本端软删)→updated_at必须绑与deleted_at同一个参数(W)。
并断言 pull 站点恰好 10 处(加第 11 张同步表而漏改,测试直接红)。 三条规则各注入一次缺陷实证过(P2 去掉 MAX / P1 换成本端 now / W 删掉 updated_at), 三次都红且报出正确的那一条 —— 这条守卫不是装饰。
3.4 顺手补掉的一处潜在入口:mark_synced(P3)
common.rs::mark_synced 原本写 synced_at = COALESCE(updated_at, ?now)。 对本端产生的墓碑没问题(规则 W ⇒ updated_at == deleted_at); 但对从对端拉回来的墓碑(updated_at 停在对端删除前的旧值,可能远早于 deleted_at), 它一旦再次变脏被重推,mark 之后 deleted_at > synced_at 立刻恒真 = 回声环换个入口复发, 且永久不是一次 —— 与 RVH _markSynced 是同一个形状。
现已改为:
sql
UPDATE <表> SET synced_at = MAX(COALESCE(updated_at, ?1), COALESCE(deleted_at, '')) WHERE id IN (...)⚠️ 诚实标注:RB 侧当前没有已知可达路径(pull 墓碑分支写完即 clean,之后不会无故变脏)。 这是把不变式钉死,不是修一个正在出事的 bug。RVH 侧则是真实可达的(见 §5.3)。
4. doc 37 请求裁定的两个边界
4.1 远端 updated_at 为 NULL 时取什么 → 回落 created_at,不是 deleted_at,更不是 now
sync_ts = 远端 updated_at ?? 远端 created_at // created_at 在 10 张表上都是 NOT NULL
synced_at = MAX(sync_ts, deleted_at)三个候选,逐个说明为什么:
| 候选 | 裁定 | 理由 |
|---|---|---|
created_at | ✅ 采用 | 恒非空;且 MAX(created_at, deleted_at) 里它必然是较小的那个,等于「让 MAX 退化成 deleted_at」——既满足 P2 又不引入新语义 |
直接用 deleted_at | ⚠️ 不推荐 | 墓碑分支下结果通常相同,但它把 updated_at > synced_at 那一支的保护丢了:万一远端 updated_at 晚于 deleted_at(对端先删后又被另一端 touch),该行立刻从另一支变脏。既然 MAX 已经在了,白拿这层保护 |
本端 now | 🔴 禁止 | 违反 P1,就是本单要修的那个缺陷本身 |
| 直接留 NULL | 🔴 禁止 | 见下 |
🔴 NULL 陷阱(改的时候最容易在这里换个身份复发):SQLite 的多参
MAX()只要有一个操作数是 NULL 就整体返回 NULL。所以MAX(remoteUpdatedAt, deletedAt)在remoteUpdatedAt为 NULL 时给出synced_at = NULL→ 命中脏检查第一支synced_at IS NULL→ 一模一样的回声环,只是这次是从 P2 的实现里跑出来的。 而word_page_links/reading_pages/word_cloze_contexts这三张表的updated_at在 Supabase DDL 上本来就是可空的(RBsync-tables.sql:updated_at TEXT, 无 NOT NULL),双端 push 也确实常常不写它 ⇒ 这不是边角情况,是常态。 若在 Dart 侧用??先把 null 消掉再进 SQL,等价且更直观 —— cloze 那处 (sync_repository_impl.dart:1305)已经是这个写法,可直接作模板, 只需把它末尾那个?? now的兜底换成?? remoteDeletedAt(created_atNOT NULL ⇒ 该兜底不可达, 但留着本端now在代码里就是给下一个人埋 P1)。
4.2 两端字面格式不同(RB +00:00 / RVH Z)⇒ 比较方式必须与脏检查同构
裁定:这个问题在守住 P1 之后自动消失,但代价是必须换一种理解方式。
关键在于重新框定 MAX 是干什么的:
这里的 MAX 取的不是「时间上更晚的那个」,而是「让脏谓词为假的那个」。
脏检查是 SQL 里的 deleted_at > synced_at,即对存在这一行里的字符串做字符串序比较。 所以只要 synced_at 是从同一行的那几个字符串里按同样的字符串序挑出来的最大者, deleted_at > synced_at 就必然为假 —— 与这些字符串是什么格式无关,甚至与它们是否真的 按时间排序无关。这是个纯粹自洽的构造。
由此得到三条可直接执行的规则:
- MAX 的操作数只能是该行将要存下的字符串(P2 已述)。
- 用字符串序比:SQL 的
MAX(),或 Dart 的a.compareTo(b)。 🔴 禁止 parse 成DateTime再比大小 —— 那会得到「时间序」,而脏检查用的是「字符串序」, 两者在下面这些情形里不一致,一旦不一致你算出来的「更大」在谓词眼里就是「更小」,回声环照旧:2026-08-27T10:00:00.000Zvs2026-08-27T10:00:00.000001+00:00:同一微秒级别下'Z'(0x5A) >'0'(0x30) ⇒ 字符串序判前者大,时间序判后者大。- Dart
toIso8601String()在microsecond == 0时输出 3 位小数、否则 6 位; Rustto_rfc3339()(SecondsFormat::AutoSi)输出 0/3/6/9 位 ⇒ 同一端内部也会出现小数位数不同的两个串,.500Zvs.500001Z同样反序。
- 禁止拿本端值与远端值比大小 —— 这是 1 和 2 的推论,也再一次就是 P1。 doc 35 §2.4 记的「两端格式不同但都是 UTC、字典序仍与时间序一致」这句 在跨端比较里不成立(它对同一端内部、且小数位数一致时才成立)。
换句话说:doc 37 说现有的偏向是「偶然安全」,这个判断是对的。 本单不是去证明那个偶然成立,而是把它变成不需要成立 —— 只要不跨端比较,格式差异就不参与运算。 两端的时间戳格式因此不需要统一(统一是好事,但不是本条契约的前提,别把它捆进来)。
5. 给 RVH 的落地形状(照抄即可)
5.1 五处墓碑分支:SQL 形状
以 _pullWordPageLinks(doc 37 表里的 1028 行)为例,改动 = 两处:
dart
// ❌ 现状
await db.rawUpdate(
'UPDATE word_page_links SET deleted_at = ?, synced_at = ? WHERE id = ?',
[remoteDeletedAt, now, r['id']], // now = syncStartAt,本端时钟 → 违反 P1
);
// ✅ 目标:syncTs 取自远端行;updated_at 一并覆盖;synced_at 走 MAX
final syncTs = (r['updated_at'] as String?)
?? (r['created_at'] as String?)
?? remoteDeletedAt; // 三级回落,全部来自远端行,无本端时钟
await db.rawUpdate(
'UPDATE word_page_links SET deleted_at = ?, updated_at = ?, synced_at = MAX(?, ?) WHERE id = ?',
[remoteDeletedAt, syncTs, syncTs, remoteDeletedAt, r['id']],
);要点四条:
syncTs的回落链里不许出现now—— cloze 那处现在是?? now,虽然created_atNOT NULL 让它不可达,也请一并换成?? remoteDeletedAt(P1 是形状规则,不是可达性规则)。updated_at也要写(当前_pullWordPageLinks/_pullKnownWords等不写)。 不写也能做对,但那样 MAX 的第一个操作数必须改成本地既有的updated_at, 多一层推理、多一次读;一并覆盖最省事,也与 RB 逐字同形。MAX(?, ?)用 SQLite 的多参MAX();若你更想在 Dart 里算,用syncTs.compareTo(remoteDeletedAt) >= 0 ? syncTs : remoteDeletedAt——compareTo不是DateTime.parse().isAfter()(§4.2 规则 2)。- 五张表逐个改(reading_notes:600 / reading_pages:705 / learning_entries:837 / word_page_links:1028 / known_words:1180),cloze:1320 只需改掉那个
?? now兜底。
5.2 写侧(规则 W):doc 36 已经做完了
doc 36 §2 里那三处补 updated_at bump 正是规则 W。本单只是把它写进契约, RVH 侧不需要再动代码 —— 但建议在 RVH CLAUDE.md 的对应红线里把它和本条 P1/P2/P3 收在一起, 否则「为什么删的时候非要顺手写 updated_at」这个理由会随 doc 36 归档而丢失。
5.3 _markSynced(规则 P3):RVH 这边是真实可达的
dart
// ❌ 现状(sync_repository_impl.dart:1480)
'UPDATE $table SET synced_at = ? WHERE id IN ($placeholders)', [now, ...ids]
// ✅ 目标
'UPDATE $table SET synced_at = MAX(COALESCE(updated_at, ?), COALESCE(deleted_at, \'\')) '
'WHERE id IN ($placeholders)', [now, ...ids]为什么 RVH 比 RB 更需要这条:一条从 RB 拉回来的墓碑,其 deleted_at 用的是 RB 的时钟。RVH 时钟慢时,now < deleted_at ⇒ push 完 mark 一遍还是脏 ⇒ doc 37 说的「是永久不是一次」。改成 MAX 之后,这一路单独就能收敛, 与 §5.1 的修复互为独立兜底(两条都做,别二选一)。
5.4 存量数据:不需要 backfill 脚本,修完自愈
已经写歪的行(synced_at < deleted_at)此刻正在回声环里 —— 而回声环本身保证了它们 每一轮都会被服务端重新发回来(本端 push → trigger 刷新 server_updated_at → 下一轮 pull 的 gt.last_sync_at 必然命中)。所以修复上线后第一轮 sync 这些行就会走进修好的墓碑分支、拿到正确的 synced_at、退出循环。
- 没在环里的行 = 没写歪 = 不用管;
- 在环里的行 = 一定会被拉回来 = 自动修好。
前提说明:两端内测用户可随时重装 app,所以下面这段严格来说是「不必要的好处」—— 真收不了场,让用户重装即可,不存在必须在线修复的存量。之所以还写出来,是因为 它意味着这次修复不需要任何一次性脚本或迁移。
§5.1 与 §5.3 各自单独就能让存量收敛,路径不同:改了 §5.1,行被拉回来时在墓碑分支修好; 只改 §5.3,行被重推一次、mark 时 MAX(..., deleted_at) 把它抬到 deleted_at 之上而收敛 (代价是每行多一次 push)。两条都做不是冗余 —— 它们各自挡的是对方失效时的那一半 (§1.5 的表),且都不需要 migration 或一次性修复脚本。
5.5 验证建议
- 单测:造一行
updated_at=01:15 / deleted_at=04:53的远端墓碑(= 2026-08-03 真实数据), 跑墓碑分支后断言该行不再命中脏谓词;再补一条反向断言(换回now或去掉 MAX 就复现), 否则测试可能是假绿。RB 侧对应pull/library.rs::tombstone_echo_testscommon.rs::mark_synced_tests,可直接照译。
- 结构守卫:RVH 若也想要 §3.3 那种「加第 11 张表自动红」的守卫, 形状是「扫源码 → 每条墓碑 UPDATE 二选一落在 (P1+P2) 或 W 上 → 断言 pull 站点数量」。
- 🔴 别只测「时钟正常」的情形 —— 本缺陷在时钟正常时完全不显形, 这也是它在两端各活了这么久的原因。测试里把远端
deleted_at直接写成一个比本端now大的值。
6. RB 侧本轮改动清单
| 文件 | 改了什么 |
|---|---|
CLAUDE.md §4 红线 #6d | 从「RB 侧实现细节」升格为双端契约,拆成 W / P1 / P2 / P3 四条,补 NULL 陷阱与比较方式 |
src-tauri/src/commands/sync/common.rs | mark_synced 改 MAX(COALESCE(updated_at, ?1), COALESCE(deleted_at, ''))(P3,防御性)+ 新增 mark_synced_tests(含反向断言) |
src-tauri/src/commands/sync/pull/mod.rs | 新增结构守卫 tombstone_synced_at_guard(P1/P2/W 三条 + 站点计数) |
docs/cross-end/38-*.md(本文件)· README.md | 契约单 + 总表登记 |
验证:cargo test --lib 219 passed / 0 failed;三条规则各注入一次缺陷实证(§3.3)。 未改:pull 的 10 处墓碑分支(自查全部合规,见 §3.1)—— 本轮 RB 没有修复任何真实缺陷, 产出是契约 + 守卫。
7. RVH 待办
- [ ] 五处墓碑分支按 §5.1 改(reading_notes / reading_pages / learning_entries / word_page_links / known_words)
- [ ] cloze:1320 的
?? now兜底换成?? remoteDeletedAt(§5.1 要点 1) - [ ]
_markSynced按 §5.3 改(P3) - [ ] 单测 + 反向断言(§5.5),注意别只测时钟正常的情形
- [ ] RVH
CLAUDE.md红线:把 W / P1 / P2 / P3 收在一处(W 的代码 doc 36 已经落了,缺的是条文) 并按 doc 36 §4.1 的对照表登记 RB #6d ↔ RVH 编号 - [ ] 回执一份(编号 39),确认存量是否如 §5.4 预期自愈