主题
37 · RVH→RB:pull 墓碑分支的 synced_at —— RVH 侧无对称实现,请把 #6d 升成双端契约
物理仓库位置:
~/reading_vocab_helper/docs/cross-end/37-rvh-tombstone-synced-at-handoff.mdRB 会话读法: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,它违反的是 ①:直接拿本地 now 当 synced_at。 ⇒ 照现在的条文去审 RVH,5 处全能蒙混过关。 这是本单请你们改条文的核心理由。
3. RVH 侧现状(逐行核对,可直接采信)
lib/features/sync/data/repositories/sync_repository_impl.dart, 其中 now = syncStartAt.toIso8601String()(:152,本端时钟):
| 表 | 行号 | 墓碑分支写的 synced_at | 性质① 取自远端 | 性质② 与 deleted_at 取 MAX | 判定 |
|---|---|---|---|---|---|
reading_notes | 600 | now | ❌ | ❌ | ⚠️ 暴露 |
reading_pages | 705 | now | ❌ | ❌ | ⚠️ 暴露 |
learning_entries | 837 | now | ❌ | ❌ | ⚠️ 暴露 |
word_page_links | 1028 | now | ❌ | ❌ | ⚠️ 暴露 |
known_words | 1180 | now | ❌ | ❌ | ⚠️ 暴露 |
word_cloze_contexts | 1320 | syncTs = 远端 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_at ⇒ deleted_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 完全可以今天就改。没这么做的理由是:
- 这类判据两端各写各的,就是上一轮 #6d 只修了 RB、源头留在 RVH 整整 24 天的原因
- §6 那两个边界问题,你们已经在自己那 10 处趟过一遍了,重新推一遍是浪费
- 条文若不改(§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 交付
- #6d 条文改写,把性质 ① 写进去 —— 至少要能挡住「拿本端时钟当
synced_at」这种写法。 建议措辞方向:墓碑分支的synced_at必须取自远端行的时间戳,再与本行deleted_at取 MAX;禁止以本端时钟(sync 起始时刻等)作为任一操作数。 - §6 两个边界的裁定。
- 顺手复核你们自己 10 处是否真的都合规(RVH 这边只核了 cloze 那一条)。
- 一份 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.md、 docs/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 仓** |