Skip to content

39 · RVH→RB 回执:墓碑行 synced_at 契约落地完成(W / P1 / P2 / P3)

物理仓库位置:~/reading_vocab_helper/docs/cross-end/39-rvh-tombstone-synced-at-confirmation.md 日期:2026-08-27 · 回 ~/reading-browser/docs/cross-end/38-rb-tombstone-synced-at-contract-handoff.md 本会话只动 RVH 仓(除 README 总表登记行外,RB 仓未改)。

§7 六项待办全部完成。 契约照单落地,未打折扣、未自行改判。 另有一处比交接单更严(§3.2)和两条实测反馈(§6)。


1. §7 待办对账

#待办状态
1五处墓碑分支按 §5.1 改✅ 实际改了六处(含 cloze 的 P2,见 §3.2)
2cloze ?? now?? remoteDeletedAt✅ 收进单点 _remoteSyncTs,终点是空串(理由见 §3.1)
3_markSynced 按 §5.3 改(P3)
4单测 + 反向断言,别只测时钟正常✅ 11 例,五处注入缺陷实证(§5)
5RVH CLAUDE.md 红线收 W/P1/P2/P3 + 登记编号对照✅ 新立 #6g,对照表加行
6回执(本文件)+ 确认存量是否如 §5.4 自愈✅ 有专门用例,§4

2. 改了什么

文件改动
sync_repository_impl.dart新增单点 _remoteSyncTs(P1)+ _maxIso(P2 的 Dart 侧,compareTo)· 6 处墓碑分支改 synced_at = MAX(?, ?) 并一并覆盖 updated_at · _markSyncedMAX(COALESCE(updated_at, ?), COALESCE(deleted_at, ''))(P3)
CLAUDE.md新立红线 #6g(RB #6d 的 RVH 镜像),四条子规则 + 两个坑 + 6 条注入验证过的 CI grep;#6f 里「子行 bump updated_at」改为指向 #6g,避免两处各写一份判据
test/features/sync/tombstone_synced_at_test.dart新增,11 例
docs/cross-end/39(本文件)· docs/README.md · docs/plans/backlog.md回执 + 索引 + 关闭立项

2.1 规则 W:RVH 侧本就 100% 合规,本轮零改动

全仓 SET deleted_at = ? 的本端软删站点共 11 处,逐处核对绑参 —— 全部[now, now, ...] / [ts, ts, ...] 这种同值形式:

notebook_datasource.dart(×3) · reading_note_datasource_impl.dart(×3) · app_database.dart::prunePreinstalledVocabulary(×3) · local_known_words_datasource.dart::softRemoveForUser · cloze_pool_writer.dart · sync_repository_impl.dart::_reconcileClozePool(×2)

其中最后三处补 bump 是 doc 36 §2 刚做的 —— 正如 §5.2 所说,本轮只需把它写进条文。 已在 #6g 里加了会红的 CI grep(SET deleted_at = ? 行尾不接 updated_at 即失败), 注入验证过。


3. 两处与交接单不同的判断(都是更严,不是打折)

3.1 _remoteSyncTs 的回落终点用空串,不是 remoteDeletedAt

§5.1 要点 1 建议 ?? remoteDeletedAt。RVH 落成 ?? '',理由是这样才能收成一个单点

dart
String _remoteSyncTs(Map<String, dynamic> r) =>
    (r['updated_at'] as String?) ?? (r['created_at'] as String?) ?? '';

RVH 的 cloze 里 syncTs 同时服务墓碑分支和活行分支(活行没有 deleted_at 可回落)。 若终点写 remoteDeletedAt,活行路径就得另开一个变量、或者把 now 留在那一支 —— 两条都是给下一个人埋 P1。空串则两支通用:

  • 墓碑支:MAX('', deleted_at) == deleted_at
  • 活行支:updated_at IS NOT NULL 为假(走到第三级说明它是 NULL)⇒ 不脏 ✅

且空串与你们 mark_syncedCOALESCE(deleted_at, '') 是同一个惯用法。 created_at 在远端 DDL 上 NOT NULL ⇒ 第三级本就不可达,这里只是让类型收敛成非空。

3.2 cloze 的墓碑分支也补了 P2(§7 只要求改 ?? now

§7 第 2 项只说改兜底。但 doc 37 §3.1 点名的那条「未写进契约的耦合」—— cloze 安全全靠 RB clear_cloze_context_for 恰好写同值 —— 只改兜底并不能解除。 既然 §1 已把 P2 定为无条件规则(「不能靠『反正对端会守 W』省掉」),这一处就一并加了 MAX。 ⇒ RVH 现在是 6 处 MAX,不是 5 处。CI grep 里的数量硬断言写的也是 6。


4. 存量自愈:确认符合 doc 38 §5.4 预期

有专门用例(存量自愈 组):造一条「synced_at 停在本端旧时钟、deleted_at 是对端未来时间」 的存量行 → 先断言它确实在环里(脏)→ 跑一轮 syncNow() → 断言不脏。通过。

补充一条你们没写但成立的:§5.1 与 §5.3 各自单独就能收敛这一点,在 RVH 侧也验到了 —— P3 那组用例根本没喂 pull 行(sync(db, {})),纯靠 push 后的 mark 就让本地墓碑退出了脏状态。

未做 backfill 脚本、未做 migration,与 doc 38 §5.4 一致。 ⚠️ 本文件自己的 §5.4 是另一件事(实测的 27 秒余量),别串了。


5. 验证证据

$ dart analyze lib
0 error / 0 warning(2737 条既有 style info 不变)

$ flutter test
+1270 ~5: All tests passed!      (本轮新增 11 例,无既有用例回归)

5.1 新增测试的形状

test/features/sync/tombstone_synced_at_test.dart,驱动真实 syncNow()、真实 DDL(ffi)。 两个刻意的设计:

  1. 脏谓词逐字抄自生产代码的 _pushXxx WHERE,不另写近似判断 —— 一旦和生产代码分叉,测试就变成自说自话。
  2. 所有远端 deleted_at2099-...(远晚于本端 now)。 按 §5.5 最后一条:本缺陷在时钟正常时完全不显形,随手取个过去时间的测试是假绿updated_at 则统一用 2026-08-03T01:15:00Z(= 你们那 57 行的真实形状,对端违反 W)。 两条同时成立才是最坏情况。

5.2 五处注入缺陷实证

注入结果
P1:reading_notessynced_at 换回本端 now+9 -2
P2:cloze 去掉 MAX+10 -1
P3:_markSynced 换回平铺 now+9 -2
NULL 陷阱:_markSynced 去掉 COALESCE(updated_at, ?)+10 -1
§4.2:把 MAX 换成 DateTime.parse(...).isAfter(...)+9 -1,且恰好红在 §4.2 那条用例上
全部还原后复跑All tests passed!

5.3 🔴 真机验证(2026-08-27,Android 24094RAD4C + 真实 Supabase)

单测用 2099 的远端 deleted_at 覆盖了逻辑,但没有证明生产环境里回声环真的消失。 本节补上,并附一条对 RB 也有价值的实测数据。

怎么造条件 —— 不动设备时钟

adb 改不了时钟(实测:uid=2000(shell),无 root,cannot set date: Operation not permitted), 而从 Settings UI 手动改会连累证书 / 闹钟 / 其他 app。

改用等价条件:触发条件是相对的,把对端时间戳推到未来 = 本端时钟变慢。 挑了一条已经是墓碑user_word_page_links 行(其父词已删,行本就死的,改它语义上无副作用):

sql
UPDATE user_word_page_links SET deleted_at = '2099-01-01T00:00:00.000Z' WHERE id = 'c1ba7ea1…';

这行恰好远端 updated_at 是 NULL(RVH 的 push payload 本就不含该列) ⇒ 一次同时覆盖 P1 / P2 / NULL 陷阱

结果

观察判定
本地 synced_at(pull 后)2099-01-01T00:00:00.000Z取自远端行。旧代码写本端 now2026-08-27T06:4xdeleted_at > synced_at ⇒ DIRTY ⇒ 进环
本地 synced_at 是否为 NULL✅ NULL 陷阱堵住 —— 裸写 MAX(remoteUpdatedAt, deletedAt) 在这里给 NULL ⇒ 命中脏检查第一支
server_updated_at(8+ 轮 autoSync / 9 分钟)06:33:56.044245 逐字节不变✅ 一次都没被重推 = 回声环不存在
脏谓词clean
收尾后自愈改回原值 → 下一轮 pull 自动恢复 clean✅ 顺带现场验了 doc 38 §5.4 的「存量自愈」

🔑 防假绿的对照(这一步不能省)

server_updated_at 没变」有个致命的替代解释:那段时间根本没同步。 所以同期查了 app_metadata.last_sync_attempt_at,确认它每 60 秒推进 (06:40:0806:43:08)—— sync 确实在跑。

本次差点就踩进去:第一次等了 75 秒去查,行纹丝不动,看着像「通过」。 实际是 autoSync 压根停了last_sync_attempt_at 停在 30 分钟前)—— 原因见 §6.4。若没做这个对照,会得到一个完全反向的错误结论。

5.4 📏 实测数据:旧代码的真实安全余量是 27 秒,不是「几秒」

从设备库里既有的、旧代码产物的墓碑行反算(这些行是 RB 删除、RVH 用旧代码落地的):

deleted_at(RB 时钟)synced_at(RVH 本端 now余量
learning_entries09:22:58.51509:23:43.603+45.1s
word_page_links ×209:23:16.71709:23:43.603+26.9s
reading_pages / reading_notes09:23:16.71709:23:43.603+26.9s

⇒ 本端时钟只要慢 27 秒,这几行当时就已经进环了。

doc 37 §4 / doc 38 §2 写的「几秒漂移就够」是按 autoSync 60s 估的理论最坏情况,偏紧。 真实门槛是半分钟量级 —— 结论不变(飞行模式回来 / 手动改过时间 / NTP 没追上都够), 但数字应以本节为准。RVH 侧 doc 37 与红线 #6g 已订正;RB 侧 doc 38 §2 与红线 #6d 请一并订正

5.5 顺带在真实数据里看到的两条实证

  • cloze「靠对端写同值才偶然安全」是真的(§3.1 此前只是代码推理):库里 4 条 RB 删除的 cloze 行全部 deleted_at == updated_at == synced_at。RB 写同值 → RVH 取远端 updated_at 当 syncTs → 恰好相等。RB 哪天改那一行,这张表立刻跟着炸。
  • 规则 W 在 RVH 侧的真机产物:删 festival 后 1 entry + 6 link + 1 cloze 共 8 行拿到 完全相同的微秒时间戳 05:59:04.979734Z —— 同一事务 + updated_at == deleted_at 的直接证据。

5.6 CI grep 也做了注入验证

#6g 里 6 条 grep 逐条在干净树上跑绿后,又各注入一次缺陷确认会红: P2 数量断言(n=5 变红,模拟「加第 7 张同步表漏改」)· P3 双 COALESCE · W 全仓扫描 · P1 回落链。

这一步是有教训的:doc 36 那轮写的第一版 grep 就假红过(误伤了 _pullLearningEntries 里合法的本地行存在性检查)。grep 不注入验证等于没写。


6. 给 RB 的四条反馈

6.1 §5.1 样板代码的参数顺序会绊一下

§5.1 给的是 [remoteDeletedAt, syncTs, syncTs, remoteDeletedAt, r['id']]SET deleted_at = ?, updated_at = ?, synced_at = MAX(?, ?) WHERE id = ? —— 正确, 但 syncTsremoteDeletedAt 交替出现四次,抄的时候极易错位,而错位后测试未必红MAX(a,b) 对称,只有 updated_at 那个位置写反才出错,且要在「对端违反 W」时才显形)。 RVH 是靠 §5.5 那条「远端 deleted_at 要比本端 now 大」的测试才有把握。 建议你们下次给样板时顺手标一句参数顺序

6.2 §4.1 表格里「直接用 deleted_at」那栏的理由,对 RVH 少一层

你们说不推荐它是因为「丢掉 updated_at > synced_at 那一支的保护」。 对 RVH 还多一层:word_page_links 的 push 脏检查根本没有 updated_at > synced_at 这一支 (本表双端都不写 updated_at,RVH 的 WHERE 里只有 synced_at IS NULL 和墓碑支)。 所以对这张表两个候选等价 —— 但其余五张表不等价,统一走 created_at 是对的。 记在这里只是说明 RVH 没有因为「这张表反正等价」而分头写。

6.3 一处可能值得你们复查:mark_synced 的活行路径

RVH 改完 P3 后,活行synced_at 从「本端 now」变成了「该行自己的 updated_at」 (MAX(COALESCE(updated_at, now), ''))。这是 §5.3 那条 SQL 的直接后果,不是 RVH 的发挥。

RVH 侧核过没有副作用(脏谓词是 updated_at > synced_at,取等号即 clean; retryPendingVocabPrunesynced_at < updated_at 闸门同样取等号即「已完成」)。 但你们那边 synced_at 若还被别的地方当作「上次成功同步的时刻」读(而不只是当脏标记), 语义就变了 —— 值得扫一眼。RVH 这边 synced_at 只用于脏判定,没有第二个消费方。


6.4 🔴 一条 RVH 侧的发现,可能对 RB 也成立:autoSync 会被静默停掉

autoSyncProviderProvider.autoDispose,全仓只有两个 watcher (auth_section.dart / review_overview_page.dart)⇒ 只有停在 Review 页(或含 auth_section 的页)时那个 60 秒轮询才活着。停在 Scan 页或生词本页时,同步完全不运行。

本次真机验证一开始卡了半小时就是这个原因(last_sync_attempt_at 停在 30 分钟前)。 用户在生词本里删了词、不进 Review 就一直不上行。

RVH 侧已于同日修复(v63),下面的描述保留为发现经过;现状与订正见本节末尾。

与本契约的关系:它会让任何「观察若干轮之后 X 没变化」的验证方法默认假绿。 RB 若也用「盯 server_updated_at 是否被刷新」来验回声环,请一并确认自己的定时器在观察窗口内 真的在跑。

订正与现状(2026-08-27 当日补)

上面那句「只有停在 Review 页时才活着」不准确,真实条件是「Review 页或 ProfilePage 在 Navigator 栈的任意一层」—— MaterialPageRoute 默认 maintainState: true, 从 Review push 进生词本时 Review 仍挂在栈里,定时器照旧活着。 ⇒ 同一个生词本页,从 Review 进去同步就跑、从 Me 进去就不跑。 §5.3 里那两条看似矛盾的观测(「删词 20 秒后墓碑上行」vs「等 30 分钟纹丝不动」) 就是这两条路径的差别。如果 RB 侧也去查同类问题,这一点值得先看: 「哪个页面在最上面」不是判据,「哪些页面还挂在栈里」才是。

RVH v63 的修法:宿主提到 app 根(挂在 MaterialApp.builder,即 Navigator 之上 —— 挂进 home: 或任何页面都会被 tab 切换的 pushReplacement 换掉),前台 60s 不变、 切后台暂停、回前台立刻同步一次。前置给 syncNow 补了防重入(合流语义), 否则「定时器这一拍」与「回前台触发」并发会同时动 watermark(#5d)和 cloze reconcile(#5g)。

🔴 触发时机的变化对你们的意义:RVH 的本端改动现在最迟 60 秒内上行,且不再取决于 用户停在哪个页面。此前 RB 侧若观察到「RVH 的改动迟迟不来」,不能再默认是对端没同步。

⚠️ last_sync_attempt_at 对照这条纪律不作废,只是理由换了:修复后「同步停了」 依然会合法发生 —— app 切后台就停表(这是刻意的省电设计,实测后台两拍一次都不跑)。 所以任何「观察若干轮后 X 没变化」的验证,仍必须拿它证明观察窗口内同步真的在跑。

7. 待办状态

  • [x] 六处墓碑分支(P1 + P2)
  • [x] _markSynced(P3)
  • [x] 规则 W 全仓核对(11 处,本就合规)+ 补 CI grep
  • [x] 11 例回归 + 五处注入缺陷实证 + 6 条 CI grep 注入验证
  • [x] RVH CLAUDE.md 红线 #6g + 编号对照表
  • [x] 存量自愈用例确认
  • [ ] RB 侧登记本文件到 README 总表(§8;RVH 会话已代填,但未提交
  • [x] 时钟漂移真机复验已完成(§5.3)—— 未动设备时钟,改用「把对端 deleted_at 推到未来」这一等价条件;含防假绿对照

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

markdown
| 39 | RVH 仓 `docs/cross-end/39-rvh-tombstone-synced-at-confirmation.md` | RVH 回执 | 墓碑行 `synced_at` 契约 **RVH 侧落地完成**(回 38):§7 六项待办全清。**6 处**墓碑分支改 `synced_at = MAX(?, ?)` 并一并覆盖 `updated_at`(比 §7 要求多一处 —— cloze 的 P2 也补了,见 §3.2:只改 `?? now` 并不能解除 doc 37 §3.1 点名的那条「靠 RB 写同值才安全」的耦合)· P1 收成单点 `_remoteSyncTs`**回落终点用空串不是 `remoteDeletedAt`**(§3.1:RVH 的 `syncTs` 同时服务墓碑与活行两支,空串两支通用,且与你们 `COALESCE(deleted_at, '')` 同惯用法)· `_markSynced` 落 P3 · 规则 W 全仓 11 处核对**本就合规**(doc 36 那三处 bump 已补齐),本轮只是写进条文 + 补会红的 grep。存量按 §5.4 **确认自愈**(专门用例:先断言在环里 → 跑一轮 → 不脏),**未做 backfill 或 migration**。验证:`dart analyze` 0 error · `flutter test` **1270 passed** · 新增 `tombstone_synced_at_test.dart` 11 例,**五处注入缺陷实证**(P1/P2/P3/NULL 陷阱/`DateTime.parse` —— 最后一条恰好红在 §4.2 那条用例上)· 6 条 CI grep 逐条注入验证(含「加第 7 张同步表漏改就红」的数量硬断言)。RVH 新立红线 **#6g**(RVH 的 #6d 号被 `last_opened_at` 占着)。四条反馈见 §6:①§5.1 样板的参数顺序易抄错且**错位后测试未必红** ②§4.1 的候选对 `word_page_links` 其实等价(该表 push 脏检查没有 `updated_at > synced_at` 支)③ **请复查你们 `mark_synced` 的活行路径** —— P3 让活行 `synced_at` 从本端 `now` 变成该行自己的 `updated_at`,RVH 侧无副作用(`synced_at` 只用于脏判定),但你们若还有别处把它当「上次同步时刻」读,语义就变了。✅ **时钟漂移已真机复验(§5.3)**:adb 无 root 改不了设备时钟,改用等价条件 —— 把一条**已是墓碑**的 link 行的远端 `deleted_at` 推到 `2099`(该行远端 `updated_at` 恰好是 NULL ⇒ **一次同时覆盖 P1 / P2 / NULL 陷阱**)。本地 `synced_at` 落成 `2099`(取自远端行;旧代码会写本端 `now`)、`server_updated_at`**8+ 轮 autoSync / 9 分钟**里逐字节不变 ⇒ 没有重推。🔑 **并做了防假绿对照**`last_sync_attempt_at` 每 60s 推进)—— 本次差点踩进去,见下条。📏 **请 RB 订正一个数字**:doc 38 §2 与红线 #6d 写的「几秒漂移就够」偏紧,从设备库里旧代码产物反算出的**实测余量是 27 秒**(另有几行 45s),结论不变但门槛是半分钟量级(§5.4)。🔴 **另一条可能对 RB 也成立的发现(§6.4)**:RVH 的 `autoSyncProvider``autoDispose`、全仓只有 2 个 watcher,**栈里没有 Review 页 / ProfilePage 时 60 秒轮询就停**(注意判据是「还挂在栈里」不是「在最上面」—— push 进子页时下层仍挂着,所以同一个生词本页从 Review 进去会同步、从 Me 进去不会)—— 它会让任何「观察若干轮后 X 没变化」的验证默认**假绿**,RB 若也靠盯 `server_updated_at` 验回声环,请确认观察窗口内自己的定时器真在跑。✅ **RVH 已于同日修复(v63)**:宿主提到 app 根(挂 `MaterialApp.builder`,Navigator 之上)+ 切后台暂停 / 回前台立刻同步,并前置给 `syncNow` 补了防重入。⇒ **RVH 的本端改动现在最迟 60 秒内上行、不再取决于用户停在哪个页面**,RB 侧不能再把「RVH 改动迟迟不来」默认成对端没同步。⚠️ 但 `last_sync_attempt_at` 对照这条纪律**不作废** —— 修复后 app 切后台仍会(刻意地)停表。**文件在 RVH 仓** |