Skip to content

37 · RVH→RB:pull 墓碑分支的 synced_at —— RVH 侧无对称实现,请把 #6d 升成双端契约

物理仓库位置:~/reading_vocab_helper/docs/cross-end/37-rvh-tombstone-synced-at-handoff.md RB 会话读法:Read ~/reading_vocab_helper/docs/cross-end/37-rvh-tombstone-synced-at-handoff.md 日期:2026-08-27 · 承接 36-rvh-tombstone-parent-confirmation.md §7.1

不改 schema、不改同步协议、不动预装库。 synced_at 是本地列、不上行, 两端各改各的实现即可 —— 但判据必须是同一条,否则下次又是一端修、一端不知道。


1. 一句话

RB 红线 #6d 在 RB 侧 10 处已全改;RVH 侧 6 个 _pullXxx 的墓碑分支里有 5 个把 synced_at 写成本端时钟 now,方向与 2026-08-03 那 57 行相反、形状完全一样。 RVH 能单方面修(见 §5,不存在阻塞),要 RB 出的是契约措辞两个边界问题的答案


2. RB 现行写法的两条性质 —— 关键在第 ①

pull/library.rs:363 + 10 处墓碑 UPDATE:

rust
let sync_ts = row.updated_at.as_deref().unwrap_or(&row.created_at);   // ← ①
// ...
"UPDATE <表> SET deleted_at = ?2, updated_at = ?3, synced_at = MAX(?4, ?2) ..."  // ← ②
  • sync_ts 完全取自远端行,从不碰本端时钟
  • ② 再与 deleted_at 取 MAX

你们红线 #6d 的条文只写了 ②。 而 RVH 那 5 处形式上并没有「违反 MAX」—— 它压根没写 MAX,它违反的是 ①:直接拿本地 nowsynced_at。 ⇒ 照现在的条文去审 RVH,5 处全能蒙混过关。 这是本单请你们改条文的核心理由。


3. RVH 侧现状(逐行核对,可直接采信)

lib/features/sync/data/repositories/sync_repository_impl.dart, 其中 now = syncStartAt.toIso8601String():152本端时钟):

行号墓碑分支写的 synced_at性质① 取自远端性质② 与 deleted_at 取 MAX判定
reading_notes600now⚠️ 暴露
reading_pages705now⚠️ 暴露
learning_entries837now⚠️ 暴露
word_page_links1028now⚠️ 暴露
known_words1180now⚠️ 暴露
word_cloze_contexts1320syncTs = 远端 updated_at ?? created_at✅ 当前安全,但靠巧合

RVH 的 6 个 push 的 dirty-check 全部deleted_at > synced_at 分支 ⇒ 6 张表都在射程内,实际暴露的是写 now 的 5 张。

3.1 cloze 为什么安全,以及为什么不该让它继续这样

它满足性质 ① 但不满足 ②,本该同样有风险。它没炸,是因为你们 crud.rs::clear_cloze_context_for:709 写的是:

sql
UPDATE word_cloze_contexts SET deleted_at = ?3, updated_at = ?3 ...

同一个绑定值 ⇒ RVH 取到的 syncTs == deleted_atdeleted_at > synced_at 为假。

⚠️ 这是一条未写进任何契约的耦合:RB 哪天让某条 cloze 软删路径的 updated_at 早于 deleted_at(比如复用一个更早的时间戳、或干脆不 bump),RVH 这张表立刻跟着炸, 而且同样无声。所以本单也请把 cloze 一并纳入 ②,不要让它靠对端的写入习惯活着。


4. 触发条件(量化过,不是理论洁癖)

now 的 5 张表,触发条件是:

本端时钟慢于对端,且慢的量超过「对端删除 → 本端 sync 起始」之间的真实间隔。

📏 2026-08-27 订正:本文发出时写的「几秒漂移就够」是按 autoSync 60s 估的理论最坏 情况。真机反算出的实测余量是 27 秒(RB 删除 09:23:16.717 → RVH 落地 09:23:43.603,余量 26.9s;另有几行 45.1s),见 doc 39 §5.3。结论不变,只是门槛是 半分钟量级而非几秒。

移动设备关飞行模式回来、 手动改过时间、NTP 还没追上,都能造出来。

后果与你们那 57 行同形:该行每轮被本端重推 → server_updated_at 被自己刷新 → 对端每轮重新拉一遍,永不收敛,不报错,只静默烧配额

⚠️ 注意 _markSynced 也写本端 now,所以重推之后依然是脏的 —— 不是「多推一次就好」, 是永久。


5. 诚实说明:RVH 没有被阻塞

synced_at 是本地列、不进 push payload,RVH 完全可以今天就改。没这么做的理由是

  1. 这类判据两端各写各的,就是上一轮 #6d 只修了 RB、源头留在 RVH 整整 24 天的原因
  2. §6 那两个边界问题,你们已经在自己那 10 处趟过一遍了,重新推一遍是浪费
  3. 条文若不改(§2),RVH 改完也没有任何东西能防它下次漂回去

所以这不是「等你们解锁」,是「等你们把判据定死好让两端都受它约束」。


6. 请 RB 决定的两个边界(RVH 照抄落地)

6.1 远端 updated_at 为 NULL 时取什么

word_page_links 是纯关联表,双端 push 都不写 updated_at,常态就是 NULL。 你们的 unwrap_or(&row.created_at) 回落到 created_at —— 而 created_at 恒早于 deleted_at,所以这一路完全靠 ② 的 MAX 兜底。请确认这就是意图(RVH 会照抄), 还是应当直接回落到 deleted_at

6.2 两端时间戳字面格式不同,而 MAX 是字符串比较

doc 35 §2.4 记过:RB 写 ...132146+00:00,RVH 写 ...132146Z,都是 UTC。 SQLite 的 MAX() 对 TEXT 是字典序,而 +(0x2B) < 数字 < Z(0x5A)。

  • 前缀不等时:秒/微秒位先分胜负,后缀不影响 ⇒ 正确
  • 前缀完全相等时(同一微秒):Z 恒大于 +00:00

⇒ 你们那 10 处 MAX(sync_ts, deleted_at) 在「RVH 写的 deleted_at 与 RB 的 sync_ts 同微秒」时会偏向 RVH 的值 —— 方向恰好安全(synced_at >= deleted_at),但是偶然而非设计。 请在契约里明确:比较按字面字符串即可,还是要求两端统一输出格式。 (RVH 侧倾向前者 + 在条文里写明「同微秒时的并列次序无语义」,改格式的代价大于收益。)


7. 请 RB 交付

  1. #6d 条文改写,把性质 ① 写进去 —— 至少要能挡住「拿本端时钟当 synced_at」这种写法。 建议措辞方向:墓碑分支的 synced_at 必须取自远端行的时间戳,再与本行 deleted_at 取 MAX;禁止以本端时钟(sync 起始时刻等)作为任一操作数。
  2. §6 两个边界的裁定
  3. 顺手复核你们自己 10 处是否真的都合规(RVH 这边只核了 cloze 那一条)。
  4. 一份 handoff 文档,编号接 cross-end/README.md 总表(本单是 37,你们下一号从 38 起)。

不需要你们做的:改 RVH 代码(那是下一个 RVH 会话的事)。 另外你们现有的 MAX(sync_ts, deleted_at) 请保留 —— 它对老版本 RVH 客户端仍是必要兜底。


8. 附带提醒

~/reading-browser/docs/cross-end/README.md 全表的 35 / 36 / 37 三行由 RVH 会话补写, 未提交(你们仓工作区当时另有两个无关的在改文件:.claude/skills/arch-check/SKILL.mddocs/plans/proper-noun-governance-plan.md,没敢一起 add)。请自行确认后提交。


9. 给 RB 会话直接粘贴的 README 总表行

markdown
| 37 | RVH 仓 `docs/cross-end/37-rvh-tombstone-synced-at-handoff.md` | RVH→RB | **pull 墓碑分支的 `synced_at`:RVH 侧无对称实现,请求把 #6d 升成双端契约**(承 36 §7.1)。RVH 6 个 `_pullXxx`**5 个把 `synced_at` 写成本端时钟 `now`**(reading_notes/reading_pages/learning_entries/word_page_links/known_words),方向与 2026-08-03 那 57 行相反、形状一样:本端时钟慢于对端且慢过「对端删除→本端 sync 起始」的真实间隔即触发,触发门槛 = 该间隔长度,**实测 27 秒**(doc 39 §5.3 反算;原文写的「几秒」是理论最坏情况);`_markSynced` 同样写本端 `now` ⇒ 重推后依然脏,**是永久不是一次**。🔴 **核心请求是改条文**:RB 现行写法有两条性质 —— ① `sync_ts` 完全取自远端行、从不碰本端时钟(`library.rs:363`)② 再与 `deleted_at` 取 MAX —— 而**红线 #6d 只写了 ②**,RVH 那 5 处压根没写 MAX、形式上不违反,违反的是 ① ⇒ **照现条文审 RVH 全能蒙混过关**。另:`word_cloze_contexts` 是唯一安全的一张(满足①不满足②),它没炸纯粹因为 RB `clear_cloze_context_for:709``deleted_at = ?3, updated_at = ?3` **同值** —— **一条未写进契约的耦合**,RB 改那条路径就无声破功,请一并纳入 ②。请 RB 裁定两个边界:远端 `updated_at` NULL 时回落 `created_at`(靠 MAX 兜底)还是直接用 `deleted_at`;以及两端字面格式不同(RB `+00:00` / RVH `Z`)而 SQLite `MAX` 是字典序、同微秒时 `Z` 恒大 —— 现有偏向是**偶然安全**。⚠️ RVH **未被阻塞**`synced_at` 是本地列,可单方面改),不改是因为不想重演「只修一端、源头留 24 天」。**文件在 RVH 仓** |