Skip to content

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_atupdated_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_atupdated_at < deleted_at 的行一落地就是脏的。 2026-08-03 那 57 行、以及 doc 36 §2 里 deleteBook 那处,都是这条被违反的产物。

🔑 doc 37 §7.1 问的「cloze 为什么安全」,答案就是这条:RB 的 vocabulary/crud.rs::clear_cloze_context_fordeleted_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 可空?判定
1learning_entriespull/vocab.rs:123&row.updated_at❌ NOT NULL
2word_cloze_contextspull/vocab.rs:300updated_at ?? created_at✅ 可空
3known_wordspull/vocab.rs:498&row.updated_at❌ NOT NULL
4favorite_sitespull/prefs.rs:69&row.updated_at❌ NOT NULL
5rss_feedspull/prefs.rs:175&row.updated_at❌ NOT NULL
6domain_prefspull/prefs.rs:301&row.updated_at❌ NOT NULL
7reading_notespull/library.rs:81&row.updated_at❌ NOT NULL
8reading_pagespull/library.rs:235updated_at ?? created_at✅ 可空
9word_page_linkspull/library.rs:370updated_at ?? created_at✅ 可空
10page_annotationspull/library.rs:484&row.updated_at❌ NOT NULL

三张可空的(#2 / #8 / #9)恰好就是 supabase/sql/sync-tables.sqlupdated_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 上本来就是可空的(RB sync-tables.sqlupdated_at TEXT, 无 NOT NULL),双端 push 也确实常常不写它 ⇒ 这不是边角情况,是常态。 若在 Dart 侧用 ?? 先把 null 消掉再进 SQL,等价且更直观 —— cloze 那处 (sync_repository_impl.dart:1305)已经是这个写法,可直接作模板, 只需把它末尾那个 ?? now 的兜底换成 ?? remoteDeletedAtcreated_at NOT NULL ⇒ 该兜底不可达, 但留着本端 now 在代码里就是给下一个人埋 P1)。

4.2 两端字面格式不同(RB +00:00 / RVH Z)⇒ 比较方式必须与脏检查同构

裁定:这个问题在守住 P1 之后自动消失,但代价是必须换一种理解方式。

关键在于重新框定 MAX 是干什么的:

这里的 MAX 取的不是「时间上更晚的那个」,而是「让脏谓词为假的那个」。

脏检查是 SQL 里的 deleted_at > synced_at,即对存在这一行里的字符串做字符串序比较。 所以只要 synced_at 是从同一行的那几个字符串里按同样的字符串序挑出来的最大者, deleted_at > synced_at 就必然为假 —— 与这些字符串是什么格式无关,甚至与它们是否真的 按时间排序无关。这是个纯粹自洽的构造。

由此得到三条可直接执行的规则:

  1. MAX 的操作数只能是该行将要存下的字符串(P2 已述)。
  2. 用字符串序比:SQL 的 MAX(),或 Dart 的 a.compareTo(b)。 🔴 禁止 parse 成 DateTime 再比大小 —— 那会得到「时间序」,而脏检查用的是「字符串序」, 两者在下面这些情形里不一致,一旦不一致你算出来的「更大」在谓词眼里就是「更小」,回声环照旧:
    • 2026-08-27T10:00:00.000Z vs 2026-08-27T10:00:00.000001+00:00:同一微秒级别下 'Z'(0x5A) > '0'(0x30) ⇒ 字符串序判前者大,时间序判后者大
    • Dart toIso8601String()microsecond == 0 时输出 3 位小数、否则 6 位; Rust to_rfc3339()SecondsFormat::AutoSi)输出 0/3/6/9 位 ⇒ 同一端内部也会出现小数位数不同的两个串,.500Z vs .500001Z 同样反序。
  3. 禁止拿本端值与远端值比大小 —— 这是 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']],
);

要点四条:

  1. syncTs 的回落链里不许出现 now —— cloze 那处现在是 ?? now,虽然 created_at NOT NULL 让它不可达,也请一并换成 ?? remoteDeletedAt(P1 是形状规则,不是可达性规则)。
  2. updated_at 也要写(当前 _pullWordPageLinks / _pullKnownWords 等不写)。 不写也能做对,但那样 MAX 的第一个操作数必须改成本地既有的 updated_at, 多一层推理、多一次读;一并覆盖最省事,也与 RB 逐字同形。
  3. MAX(?, ?) 用 SQLite 的多参 MAX();若你更想在 Dart 里算,用 syncTs.compareTo(remoteDeletedAt) >= 0 ? syncTs : remoteDeletedAt —— compareTo 不是 DateTime.parse().isAfter()(§4.2 规则 2)。
  4. 五张表逐个改(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_tests
    • common.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.rsmark_syncedMAX(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 预期自愈