主题
CLAUDE.md — RVH 跨端 Sync 协议红线(正文)
本文件由
rvh/CLAUDE.md的同名一节于 2026-08-29 原样下沉而来(docs/plans/archive/post-merge-repo-optimization-plan.mdT2-1),内容一字未改。 索引与编号对照表留在rvh/CLAUDE.md(对照表是跨仓消歧工具,任何 RVH 会话都可能需要)。 注入是祖先链全量:碰rvh/lib/features/sync/下任何文件时,本文件与rvh/CLAUDE.md一起来。
🔄 跨端 Sync 协议红线(本节只留 Dart 落地形状 + 守卫)
🔒 2026-08-28 合仓后,本节不再复述契约(
docs/plans/archive/rvh-merge-plan.mdT3-8)。 合仓前这里是 728 行 / 67KB 的 RB 红线镜像 —— 同一条规则两处叙述,就是第二本账。 它已经漂过:#6g 的触发门槛,RB 侧写「几秒漂移就够」(按 autoSync 60s 估的理论最坏值), 而 RVH 2026-08-27 真机反算是 27 秒;两个数字在两个仓里各自躺着,谁也不知道对方写了什么。现在的分工:
问什么 去哪 规则是什么 · 为什么 · 反例 · 事故记录 根 CLAUDE.md§4 ·src-tauri/CLAUDE.md§2 ·docs/cross-end/RVH 这边长什么样 · 拿什么 grep 守住 · 本端独有的坑 本节 改契约 → 改根;改 Dart 落地 → 改这里。两边都动的,先改根。 ⚠️ 本节保留的 grep 块绑死 Dart 文件名与函数名,只能长在这里 —— 删掉它们等于把判据一起删了。
🔴 谁在执行:目前没有人。 这些块合仓前标着「自动化校验(CI grep)」,但 2026-08-28 实测 没有任何 CI job / 脚本 / skill 引用它们(
ci-rvh.yml只跑 analyze + 架构 grep + test)。 它们是判据规格,不是闸门。真正在守的是每条末尾点名的那些 Dart 回归测试 (tombstone_synced_at_test.dart等,多数带「注入缺陷实证」)—— 那些确实每次flutter test都跑。 把这些 grep 接进ci-rvh.yml已登记为docs/plans/archive/rvh-merge-plan.md§7 的待决项; 在接上之前,别把「grep 写着」当成「已经守住」。
#5b user_id 必填 — save 路径 + pull 路径双侧守门
契约 → src-tauri/CLAUDE.md §2 #5b。
RVH 落地(v46):
- Schema:4 张表
user_id TEXT NOT NULL(开发期删库重建,清掉既存 NULL 行) - save 路径:4 个 Model 加
required String userId;4 个 Repository 构造期绑定 (NotebookRepositoryImpl/BookRepositoryImpl/ReadingPageRepositoryImpl, 各自 provider 注入currentUserIdProvider;KnownWordsRepositoryImpl是模板) - pull 路径:
sync_repository_impl.dart4 个_pull*的 INSERT 列表加user_id列 +_userId值
本端独有的坑:RVH 的 clearLearningDataForUser 不按 user_id 过滤(hard DELETE), 而 user_id NULL 的行无法被任何按 user_id 的 sync 路径识别 —— 这是 RVH 侧 NULL 行能长期滞留的根, 后续 #5h / #5d 两条都被它牵连。实证:Bug D,free 一词在本地共存 2 行(自加 + 从 RB pull)。
守卫判据(⚠️ 见节首「谁在执行」):
bash
# 4 张表的 INSERT 调用点必须包含 user_id 字段(任一失败即 CI fail)
for table in learning_entries reading_notes reading_pages word_page_links; do
if ! grep -A 10 "INSERT.*INTO ${table}" lib/features/sync/data/repositories/sync_repository_impl.dart | grep -q 'user_id'; then
echo "❌ pull path missing user_id for ${table}"; exit 1
fi
done
# Model toDatabase 必须包含 user_id
grep -L "'user_id'" \
lib/features/vocabulary_notebook/data/models/notebook_entry_model.dart \
lib/features/reading_notes/data/models/reading_note_model.dart \
lib/features/reading_tracking/data/models/reading_source_model.dart \
lib/features/reading_tracking/data/models/word_source_relation_model.dart \
&& (echo "❌ model toDatabase missing user_id"; exit 1) || true关联:cross-end log docs/cross-end/archive/01-vocabulary-word-pk-log.md「Bug D」段。
#5d Watermark = server_updated_at(trigger 维护的权威服务器时钟)
契约 → src-tauri/CLAUDE.md §2 #5d / #5a / #5d′(含 replica lag 与 client 时钟漂移那两个被证伪的假设)。
RVH 落地:
supabase_sync_datasource.dart::pullRows—cursorCol默认'server_updated_at'sync_repository_impl.dart::syncNow— 每个_pullXxx返回({int count, String? maxObserved}), 跨表取 MAX →_setLastSyncAt(watermark);各_pullXxx内部 cursor =rows.last['server_updated_at']- 首次升级兜底:
app_metadata.key='server_updated_at_migration_v1',未标记时清空last_sync_at触发全量 pull - 各
pullXxx仍用INSERT OR IGNORE/ON CONFLICT(id) DO UPDATE,重叠拉取 idempotent
🔒 本端独有:watermark 必须 per-user(v58,RB 无等价条款) key = last_sync_at:<userId>(SyncMetadataKeys.watermarkFor,见 lib/core/constants/sync_metadata_keys.dart),禁止退回全局单值;用户切换时 clearLearningDataForUser 在清库的同一事务内删掉离开用户的 key。
理由:watermark 语义是「本地这份数据集是谁的、消费到哪了」,而用户切换会清空本地数据集。 全局单值下新用户带着上一个用户的 cursor 做增量 pull,server_updated_at 落在两者之间的行被永久跳过。 该缺陷长期被 syncNow 的 force-full-pull 兜底掩盖,但兜底在有残留已同步行时失效,实测两条路径: ① known_words.user_id 可为空而清库按 WHERE user_id = ? 过滤 ⇒ 空值行永远删不掉 ⇒ 兜底被永久禁用 (v59.2 已修,见 #5h;但 per-user 仍是主防线);② auth_repository_impl.dart:39 的 _controller.add(user) 排在 :42 的 await _detectUserSwitch 之前 ⇒ autoSync 首次 sync 与清库事务并发,旧用户的行仍在 ⇒ 兜底不触发。
per-user 之后:到来的用户读自己的 key(通常不存在 → 全量 pull),清理删的是离开用户的 key —— 读写碰不同 key,先后顺序不影响正确性。注意 app_metadata 不是同步表, 本款是本端实现细节、不构成跨端契约(RB 自行保存其 watermark)。 回归 test/features/sync/watermark_per_user_test.dart。
守卫判据(⚠️ 见节首「谁在执行」):
bash
# pullRows 默认 cursorCol 必须是 server_updated_at
grep -q "cursorCol = 'server_updated_at'" \
lib/features/sync/data/datasources/supabase_sync_datasource.dart \
|| (echo "❌ pullRows default cursor not server_updated_at"; exit 1)
# 各 _pullXxx 不得遗留 rows.last['updated_at'] / rows.last['created_at'] cursor 推进
if grep -E "cursor = rows\.last\['(updated_at|created_at)'\]" \
lib/features/sync/data/repositories/sync_repository_impl.dart; then
echo "❌ pull cursor regression: must use server_updated_at"; exit 1
fi
# syncNow 不得遗留 sync_start_at - 5s 逻辑
if grep -q "subtract(const Duration(seconds: 5))" \
lib/features/sync/data/repositories/sync_repository_impl.dart; then
echo "❌ legacy -5s watermark buffer still present"; exit 1
fi
# v58: watermark 必须 per-user,不得退回全局单值裸 key
grep -q "String get _lastSyncAtKey => SyncMetadataKeys.watermarkFor(_userId)" \
lib/features/sync/data/repositories/sync_repository_impl.dart \
|| (echo "❌ #5d: watermark key not per-user"; exit 1)
# 用户切换必须在清库事务内删掉离开用户的 watermark 行
grep -q "SyncMetadataKeys.watermarkFor(userId)" \
lib/features/auth/data/repositories/auth_repository_impl.dart \
|| (echo "❌ #5d: clearLearningDataForUser missing watermark delete"; exit 1)
# 那行 no-op 不得复活(prefs 里从来没有 last_sync_at)——排除注释行,
# 否则会被解释「为什么删掉它」的注释本身误伤
if grep -v -E "^\s*//" \
lib/features/auth/data/repositories/auth_repository_impl.dart \
| grep -q "_prefs.remove('last_sync_at')"; then
echo "❌ #5d: no-op prefs watermark reset came back"; exit 1
fi#5e 预装库单点 + Lemmatizer JSON 双端 SHA256 必须 byte-equal
契约 → 根 CLAUDE.md §4 #10 与 #9(normalize 归一)。
🔴 两类资产从 2026-08-29(rvh-merge-plan T4-5)起判据不同,别混着说:
- 预装库
assets/databases/lampio_dict.db= 一个 symlink,指向../../../src-tauri/assets/lampio_dict.db。双端不再是「两份相等」而是只有一份 ⇒ 本端没有可以漂移的对象,也没有任何要接收 / 校验 / 覆盖的动作。 实测flutter build bundle跟随 symlink(产物是真实 65,777,664 B 的 db,AssetManifest.bin有它), 运行时照旧rootBundle.load('assets/databases/lampio_dict.db'),与 symlink 无关。 ⚠️ Windows clone 上它不是文件(gitcore.symlinks=false会落成 40 字节路径文本)—— 在 Windows 上碰rvh/前先git config core.symlinks true再重新 clone。 assets/nlp/*.json仍是货真价实的两份(RBbuild_dict产物的副本), byte-equal 照旧靠断言守(scripts/cross-end-check.sh§C)。下面那段双端验证流程说的是这一类。
2026-06-15 资产折叠后 lemma_* 三表随预装库走,JSON 降级为 pipeline 构建输入。 资产变更史(v26 的 178 个 surface 修正 / v27 的 4 条补漏 / 各版 SHA)→ docs/cross-end/24-rb-lemmatizer-asset-v26-reseed-handoff.md · docs/cross-end/28-rb-lemmatizer-residual-v27-confirmation.md · CHANGELOG.md。 合仓后 RB 是唯一产源(tools/vocabulary_builder_v3/),RVH 纯消费。
RVH 落地:lib/core/nlp/lemmatizer.dart 词典优先 4 层 (Layer 1 surface_to_base → Layer 2 base_forms 自映射 → Layer 3 _proposeStem + base_forms 裁决 → Layer 4 fallback)。 Flutter 启动期 loadLemmatizerFromAssets() 从 rootBundle 读预装库;CLI / test 用 dart:io File 加载。 assets/nlp/*.json 仅作 CLI/测试 fixture(RB build_dict 产物的 byte-equal 副本,不是 shipped runtime 资产), 见 assets/nlp/README.md。Layer 3/4 算法代码两端各一份的对齐义务照旧(折叠只合并数据)。
🔒 本端独有:跑 lemmatizer JSON 双端 byte-equal 验证时的两个陷阱
bash
# RB 端
cargo run --manifest-path src-tauri/Cargo.toml --example phase0_normalize --release --quiet \
< rvh/test/data/lemma-regression.txt > /tmp/rb-output.csv
# RVH 端:必须编成可执行文件跑,不要用 `dart run`(build hooks 会往 stdout 写东西,污染 CSV)
# ⚠️ `dart compile exe` 自 Dart 3.10 起对本项目失效:
# 'dart compile' does not support build hooks, use 'dart build' instead.
# (objective_c 等依赖引入了 build hooks。)改用 `dart build cli`:
(cd rvh && dart build cli -o /tmp/rvh-p0) && \
/tmp/rvh-p0/bundle/bin/phase0_normalize < rvh/test/data/lemma-regression.txt > /tmp/rvh-output.csv
diff /tmp/rb-output.csv /tmp/rvh-output.csv # 必须为空
dart build cli的产物是目录(<out>/bundle/bin/<入口名>+<out>/bundle/lib/*.dylib), 不是单文件;入口取pubspec.yaml配的默认值(本项目 =bin/phase0_normalize.dart)。 该命令当前标注 preview,提示打在 stderr,不污染 stdout。
🔴 另一组证据(
docs/cross-end/下的phase0_inputs.txt+rb.csv+rvh.csv)用之前先看 layer 词表: 当前应只出现dictionary/base/fallback(+empty)。出现词表以外的标签、或这几个标签消失 = 实现换代而文件没跟上,立刻用上面的命令重新生成,别拿它当验收依据。 这不是多虑:那组文件曾在旧架构(suffix/irregular/exception)上停了整整四个月无人察觉 —— 失效方式极隐蔽,两端一起陈旧,故两个 csv 彼此仍逐字节相同 → 任何「diff 两个 csv」的检查照旧假绿。 经过见docs/cross-end/26-rvh-phase0-evidence-stale-report.md。
关联:docs/cross-end/13-lemmatizer-fold-reseed-handoff.md · docs/cross-end/27-rvh-lemmatizer-residual-scan-handoff.md · docs/cross-end/09-phrase-asset-fix-reseed-handoff.md · 计划 rvh/docs/plans/lemmatizer-dictionary-refactor-rvh.md。
#5f Sync push 必经 onConflict(非 PK UNIQUE 表)
🔒 RB 侧没有对应的编号红线,本条是这条约束在本仓的唯一叙述 —— 别删成指针。
规则:所有 sync push 路径必须用 from(table).upsert(rows, onConflict: '...') (supabase_flutter SDK,等价 PostgREST ?on_conflict={cols} + Prefer: resolution=merge-duplicates), onConflict 列必须匹配目标表的非 PK UNIQUE 约束列清单。仅 PK(id) 冲突的表可省略。
理由:PostgREST 默认按 PK(id) 解决冲突,而 v43 schema 引入了多张同步表的非 PK UNIQUE:
| Supabase 表 | UNIQUE 约束 | 必传 onConflict |
|---|---|---|
| user_learning_entries | (user_id, word) | 'user_id,word' |
| user_word_page_links | (learning_entry_id, reading_page_id) | 'learning_entry_id,reading_page_id' |
| user_known_words | (user_id, word) | 'user_id,word' |
| user_word_cloze_contexts | (user_id, word, sentence) | 'user_id,word,sentence' |
| user_reading_notes | 仅 PK(id) | 不传 |
| user_reading_pages | 仅 PK(id) | 不传 |
跨端并发推同一逻辑行(不同 id)会撞 PostgreSQL 23505 / HTTP 409,UPSERT 不触发 → push 失败。 Bug G(2026-04-30 RB 端 due 测暴露)实证:双端真并发 add 同词,间隔 28ms 即触发。 早期 Bug D / Bug F 未修时,RVH push 永久失败(user_id NULL 被 NOT NULL/RLS 拒)掩盖了此问题, 双端 user_id 真值首次对齐后立刻浮现。
RVH 落地:supabase_sync_datasource.dart::pushRows 接受 String? onConflict; sync_repository_impl.dart 的 5 个 push 函数按表的 UNIQUE 约束传参。 删掉了原来 catch 23505 + UPDATE WHERE id 的兜底(在 (user_id, word) UNIQUE 上 silent fail, 且对端 UPSERT 改写 id 后 UPDATE 找不到行)。
守卫判据(⚠️ 见节首「谁在执行」):
bash
# pushRows 的所有 call site 必须显式传 onConflict 或注释「仅 PK 冲突」
grep -A 3 "pushRows(" lib/features/sync/data/repositories/sync_repository_impl.dart \
| grep -E "user_(learning_entries|word_page_links|known_words|word_cloze_contexts)" \
| xargs -I{} grep -q "onConflict:" {} || echo "❌ push call site missing onConflict"关联:RB 修复 commit a1b2f09(push.rs::post_rows 加 ?on_conflict={cols}); cross-end log docs/cross-end/archive/01-vocabulary-word-pk-log.md「Bug G」段。
#5g word_cloze_contexts 语境池:双端共写 + pull 后必经 reconcile 重新封顶
契约 → 根 CLAUDE.md §3「Auth & Sync」的 cloze 段(一词多语境池 · 应用层封顶 5 · 双端共写 · 合并后重新封顶 · 两条只对采集侧成立的约束 ①可挖空性 ②created_at 必须 UTC)。 双端共写的裁决 → docs/cross-end/23-rb-ocr-cloze-context-decision.md。
RVH 落地:
- sync:
sync_repository_impl.dart::{_pushWordClozeContexts,_pullWordClozeContexts,_reconcileClozePool,_asciiLower} - 采集(v60):
ocr_cloze_gate.dart(闸门,纯函数)→filter_confirmation_notifier.dart::_saveOcrClozeContexts(收割时收集候选、跨页择优)→cloze_pool_writer.dart::insertAll(去重 / 复活 / 封顶 / UTC 时钟,逐条镜像 RBcrud.rs::insert_cloze_context)
⚠️ 封顶 5 写在三处:
ClozePoolWriter.poolCap·sync_repository_impl._clozePoolCap· RBCLOZE_POOL_CAP。改一处必须改三处。
🔒 本端独有(RB 侧没有 OCR 腿,这些判据只长在 RVH):
- ASCII 折叠:去重键必须用
_asciiLower(仅 A–Z→a–z),匹配 SQLiteCOLLATE NOCASE+ RBto_ascii_lowercase。禁用 DartString.toLowerCase()(Unicode 感知 ⇒ 去重键错位 ⇒ 收敛发散)。 - push mapped 字典禁含
source_platform(远端user_word_cloze_contexts无此列,误加 PGRST204 全炸)。 - 删词 / 切用户必须软删该词语境(镜像 RB
clear_cloze_context_for),否则下一轮 pull 当「remote 未删活行」复活。 source_url对 RVH 采集行必须留 NULL,🚫 禁止造合成 URL(如rvh-ocr://<page_id>): RVH 的 OCRreading_pages本身source_ref就是 NULL,合成值无主;RB 的页级级联清理按source_url = reading_pages.source_ref精确等值匹配,合成值要么白造、要么恰好命中某页 ⇒ RB 把该语境错误归组到那一页并尝试打开它。NULL 不影响 RB 渲染(归 orphan 桶排在尾部,卡面照常挖空)。附带知情(既有行为,不是破口):级联键是
source_url,NULL 匹配不上任何行 ⇒ 在 RVH 删掉某张扫描页不会带走它贡献的 cloze 行;可清理路径只剩删词、句级删除、封顶轮换。- C2 每个 word 每次收割最多写 1 条:同页多次出现的词若逐词位写行,一次收割就能吃掉多个坑位 (最坏 5 个全占,把该词历史语境整体清空)。按闸门得分取最优一行。
- C1 在实现里是两条断言:①
normalize(surface) == normalize(word)(挡住 OCR 拼写纠错改过的词: 句里是heer、要存的是beer);② 直接调ClozeBuilder.buildContextCloze验可挖空性 —— 用 RB 将要跑的同一套算法,不是自写近似正则。🔑 为什么必须在采集侧执行、不能指望本端 UI 暴露问题:RVH 自己不挖空 (
_clozeBlankMode = false,只高亮目标词),buildContextCloze返回 null 时抽屉退化成纯文本、 句子照样完整显示 —— 本端看不出任何异常。同一条行到了 RB 那边是「整条语境不可见却占着坑位」。 这道闸是替对端把的关,本端正常显示不构成它合格的证据。 - 闸门方案(
length ≥ 25∧ 行左边缘落正文主列 ∧ 行末非连字符)RB 已认可无修改意见; 行末连字符那条是刚性需求不是可选优化 —— C1 的断言挡不住断词行(断出的ball确实在句里)。⚠️ 2026-08-27 真机验证后的两处修正:① 连字符必须紧贴词尾(
\w[-‐‑–]$)—— 只看行末字符会把-(破折号标点)当断词,实测一行"...his release -"误拒了months, 连带按规则 3b 拒掉 5 个行首词,27 词里 6 个白丢语境;② 主列规则默认关闭 (OcrClozeGate.enableMainColumnRule = false,实现与单测保留)—— 词位 x 由 ML Kit 行框按 字符比例插值,实测一页 18 行里 2 行报偏 140–240px;而它要挡的邻页碎片本就过不了长度闸 ⇒ 代价可测、收益存疑。🔴 改闸门前先读
ocr_cloze_gate.dart的代码注释 —— 三处判据(断词的两个方向只挡得住前半截、 主列聚类的阈值基准必须是版面内容宽度而非行左边缘跨度、ocr_word_positions.line_x实际存的是 word_x 故行左边缘要按line_text分组取 min(x))比条文细,且都实测踩过。 那些细节的真相源是代码注释,不在本文件。
待办:OCR 采集的跨端真机实测尚未做(RB 回执 §7 三步)。
守卫判据(⚠️ 见节首「谁在执行」):
bash
# C3:采集侧时钟必须 UTC —— 裸 DateTime.now() 会让本端的行系统性挤掉对端的行
grep -q 'now ?? nowUtcIso()' \
lib/features/vocabulary_notebook/data/datasources/cloze_pool_writer.dart \
|| (echo "❌ #5g C3: cloze created_at 不是 UTC 时钟"; exit 1)
# 排除注释行 —— 否则会被「解释为什么不能用 DateTime.now()」的那段注释本身误伤(同 #5d)
if grep -v -E '^\s*(//|\*|/\*)' \
lib/features/vocabulary_notebook/data/datasources/cloze_pool_writer.dart \
| grep -q 'DateTime\.now()'; then
echo "❌ #5g C3: cloze 写入路径出现裸 DateTime.now()"; exit 1
fi
# C1:可挖空性必须用 RB 的同一套算法验,不能换成自写正则
grep -q 'ClozeBuilder.buildContextCloze' \
lib/features/vocabulary_notebook/domain/services/ocr_cloze_gate.dart \
|| (echo "❌ #5g C1: 闸门没走 ClozeBuilder 验证可挖空性"; exit 1)
# 采集必经闸门:收割路径不得绕过 OcrClozeGate 直接写池
grep -q 'OcrClozeGate.pickBest' \
lib/features/vocabulary_filtering/presentation/providers/filter_confirmation_notifier.dart \
|| (echo "❌ #5g: 收割路径绕过了闸门"; exit 1)关联:RB 等价实现 src-tauri/src/commands/sync/push.rs + src-tauri/src/commands/sync/pull/ · 交接 docs/cross-end/16-rb-cloze-context-sync-handoff.md(⚠️ 其「池子归属」一节已被 doc 23 取代)· 请求 docs/cross-end/22-rvh-ocr-cloze-context-review-request.md · 落地计划 rvh/docs/plans/archive/word-cloze-contexts-sync-rvh-plan.md(2026-08-30 归档)。
#5h push dirty-check 必须按 user_id 过滤(v59)
契约 → 根 CLAUDE.md §4 #5i(含「括号是最危险处」「用 = ?1 不用 IS ?1」 「不要把 NULL 行回填成当前 user_id 再推」三款,两端同文)。
RVH 落地:6 个 push 函数(_pushBooks / _pushReadingPages / _pushLearningEntries / _pushWordPageLinkRelations / _pushKnownWords / _pushWordClozeContexts)的 dirty-check SELECT 带 WHERE user_id = ?(绑 _userId),原有 dirty 条件整体加括号。 getSyncStatus 的 pendingCount 口径与之逐表一致。
🔒 本端独有:触发路径不依赖任何竞态(这点很重要,别只盯着 auth 时序)
known_words.user_id是 RVH schema 里唯一可空的列,而clearLearningDataForUser从前按user_id = ?删它 ⇒ NULL 行任何用户都删不掉、永久残留,被之后每一个登录用户轮流认领clearLearningDataForUser若抛错(DB busy 等)会 rethrow,前一个用户的行原样留在库里- auth 广播先于清库的窗口(
auth_repository_impl.dart:39vs:42)只是又一条路径, 且它是否赢得竞争从未被实测证明 —— 修复不建立在这个假设上
v59 实测复现(test/features/sync/push_user_scoping_test.dart,驱动真实 syncNow()): 本地 6 张表各放 1 行 userA + 1 行 userB 的脏行,以 userB 身份同步 → 日志 pushed=12, 12 行全部带 user_id=userB 上行,6 张表无一幸免。
NULL 孤儿行清扫(v59.2):clearLearningDataForUser 的 known_words 删除条件由 user_id = ? 改为 user_id = ? OR user_id IS NULL,连带清掉归属不明的孤儿行 (读侧看不见、push 也推不走的纯死重,且会让 syncNow 的 force-full-pull 兜底被永久禁用,见 #5d)。
⚠️ 不要照抄 RB 的
user_id IS NULL OR user_id != ?1:两端函数语义相反。 RB 的clear_learning_data_if_user_changed传到来用户(删「不属于他的」); RVH 的clearLearningDataForUser传离开用户(_detectUserSwitch与AuthNotifier.signOut两个调用点都传lastUserId)。字面照搬会变成「删掉到来用户的数据、反而留下离开用户的」,正好删反。
word_cloze_contexts不需要这个OR:其user_id是 NOT NULL(#5b), 加了是永不命中的死谓词;schema 若放宽须同步补上。
与 auth 时序的关系:_controller.add(user) 保持在 _detectUserSwitch 之前,不要调换。 push 按 user_id 过滤(本条)+ watermark per-user(#5d)之后,清库与新用户首次 sync 读写的是 不相交行集,先后顺序不影响正确性;而调换会让清库异常导致广播丢失 ⇒ 登录成功却卡在登录页。 理由详见 auth_repository_impl.dart 构造函数注释。
🔴 2026-08-31(#6i)起判据换型:六份内联副本已收敛成常量 _kUserScopedDirty。 抽常量的直接理由是 #6i 要让 pull 侧问的问题与 push 会做的事共用同一句 SQL;顺带发现 那六份已经漂成三种形状(4 份缺 updated_at IS NOT NULL、cloze 那份完整、 word_page_links 那份整支 updated_at 条件都不在 —— 只有前者是行为等价的, 后者真的漏推过东西,见 docs/cross-end/49-rvh-tombstone-vs-remote-alive-confirmation.md §2)。
⚠️ 旧判据(数 WHERE user_id = ? + 下一行以 AND ( 开头)在今天会恒绿 —— 那个字面形状在文件里一次都不出现了(它在常量里)。换型后的判据不再是 grep, 而是真的每次 flutter test 都跑的 test/features/sync/tombstone_remote_alive_test.dart 的 结构守卫 组:
| 断言 | 注入什么会让它红(均已实证) | |
|---|---|---|
| G1a | 常量本体形状:user_id = ? 在最外层 · 脏条件整体括起来 · updated_at IS NOT NULL AND ... 在场 | 去掉外层括号 / 去掉某一支 |
| G1b | 常量之外(剥注释后)不许再出现 synced_at IS NULL 片段 | pull 侧手抄一份谓词 |
| G1c | 6 个 _pushXxx + _tombstonePendingPush 都引用它,且引用处恰好 8 个(6 push + pendingCount + 待推判据) | 某个 push 手搓回内联 / 加第 7 张表漏引用 |
⚠️ 不能改成数全文件的 WHERE user_id = ?:_reconcileRowCounts / _reconcileClozePool 等处有正当的、与脏检查无关的 user_id 过滤(实测 5 处), 那么写会恒红。
关联:RVH 回归 test/features/sync/push_user_scoping_test.dart · v59.2 清扫回归 test/features/sync/watermark_per_user_test.dart · RB 侧核实与修复交接 docs/cross-end/20-rb-push-user-scoping-handoff.md (RB 已于 2026-08-05 修完,见根 CLAUDE.md §4 #5i 的回归测试清单)。
#6b Sync pull 父表禁 INSERT OR REPLACE / ConflictAlgorithm.replace
🔒 RB 侧没有对应的编号红线(RB
pull.rs一开始就用 ON CONFLICT 风格,从未踩坑), 本条是这条约束在本仓的唯一叙述 —— 别删成指针。
规则:被其他表 FK 指向(且 FK 带 ON DELETE CASCADE)的父表,sync pull 路径禁用 SQLite INSERT OR REPLACE / sqflite ConflictAlgorithm.replace,必须用 INSERT ... ON CONFLICT(id) DO UPDATE SET ...。叶子表也统一改风格防红线漂移。
理由:INSERT OR REPLACE 的 SQLite 语义是 DELETE 旧行 + INSERT 新行; DELETE 触发 ON DELETE CASCADE 链 ⇒ 子表被级联删除;新行插入后子表已没了, 即使同循环重新拉子表也存在 race 窗口(子表行 updated_at 未变的永远 miss)。 Bug E(2026-04-29 立项 / 04-30 修复)实证 FK 链:
reading_notes (id PK)
←─ reading_pages.note_id REFERENCES reading_notes(id) ON DELETE CASCADE
←─ word_page_links.reading_page_id REFERENCES reading_pages(id) ON DELETE CASCADE
learning_entries (id PK)
←─ word_page_links.learning_entry_id REFERENCES learning_entries(id) ON DELETE CASCADE数据丢失矩阵:
| 场景 | 是否丢失 |
|---|---|
| 子表 updated_at > watermark 同循环被拉 | ⚠️ 临时丢失 → 后续 pull 重建(race 窗口) |
| reading_pages 远端未变,word_page_links 远端变了 | ❌ word_page_links 永久丢失(FK 指错) |
| 仅 reading_notes 标题变了,子表远端均未变 | ❌ word_page_links 永久丢失(不在批次) |
RVH 落地(sync_repository_impl.dart):
_pullReadingNotes(父表)→ON CONFLICT(id) DO UPDATE SET title = excluded.title, ..., 不写note_type / created_at(immutable,保留首次 INSERT 值)_pullReadingPages(父表)→ 同上,不写note_id / linked_at / created_at_pullKnownWords(叶子表)→ 统一风格,仅更新reason / updated_at / synced_at_pullNotebookEntries用INSERT OR IGNORE+ 单独 UPDATE 路径(已合规)_pullWordPageLinks用INSERT OR IGNORE(叶子表,已合规)app_metadata的INSERT OR REPLACE不在 sync pull 路径,保留
SET 子句 review 纪律:每个 ON CONFLICT DO UPDATE 的 SET 列必须严格 review, immutable 列不写 SET(created_at / note_type / source_platform / linked_at / source / added_at),否则对端误改这些列时会覆盖本地首次值。
守卫判据(⚠️ 见节首「谁在执行」):
bash
# sync pull 路径不得出现 INSERT OR REPLACE INTO <父表>
for table in reading_notes reading_pages learning_entries; do
if grep -q "INSERT OR REPLACE INTO ${table}" \
lib/features/sync/data/repositories/sync_repository_impl.dart; then
echo "❌ Bug E regression: pull path uses INSERT OR REPLACE on parent table ${table}"
exit 1
fi
done关联:cross-end log docs/cross-end/archive/01-vocabulary-word-pk-log.md「Bug E」段。
#6c 封面 BLOB 跨端契约(v47 落地)
🔒 RB 侧没有对应的编号红线,本条是这份 7 款契约在本仓的唯一叙述 —— 别删成指针。
reading_notes.cover_image_data 是字节直传字段(PostgreSQL bytea / SQLite BLOB)。 任何 push/pull 路径必须遵守:
- 三字段名:
cover_image_data/cover_image_mime/cover_source_url—— 命名敏感,差一个字符 push/pull 全炸 - PostgREST bytea hex transit:
- Push:
Uint8List→'\x' + 小写 hex(Dart 字面量r'\x'实际两字符\x, 与 RB Rustformat!("\\x{}", hex::encode(b))完全等价) - Pull:JSON 字符串 →
startsWith(r'\x')→ 去前缀 → hex decode →Uint8List - 不是 base64 /
0x前缀 / 大写 hex
- Push:
- MIME 始终
image/jpeg:双端都压 192×192 JPEG q85;用户上传 PNG/HEIC/WebP 也得在 RVHCoverImageProcessor.compressdecode → resize → encodeJpg → 写库。100KB 上限 → 拒绝,三字段全 NULL - 三字段同进同出:
cover_image_data为 NULL 时mime/source_url也是 NULL; 绝不允许 mime/source_url 有值但 data NULL 的组合 - ON CONFLICT SET 必须包含三 cover 列:
_pullReadingNotes缺一不可,否则远端封面更新永远拉不下来 - 父表禁 INSERT OR REPLACE(继承 #6b)
cover_source_url可保留 NULL:RVH 用户上传 / RB webview 多源抓取时无单一原始 URL,写 NULL 合规
落地:RVH lib/core/services/cover_image_processor.dart(image 包)+ sync_repository_impl.dart::_bytesToPostgresHex / _postgresHexToBytes; RB 等价代码 src-tauri/src/commands/sync/push.rs::push_reading_notes + src-tauri/src/commands/sync/pull/; RB 侧封面获取走 content-script webview canvas。
守卫判据(⚠️ 见节首「谁在执行」):
bash
# push 路径必须 hex 编码 cover_image_data,不能直接传 Uint8List
grep -A 20 "_pushBooks\|push_reading_notes" \
lib/features/sync/data/repositories/sync_repository_impl.dart \
| grep "cover_image_data" \
| grep -q "_bytesToPostgresHex\|hex::encode" \
|| (echo "❌ push path missing hex encoding for cover_image_data"; exit 1)
# pull 路径 ON CONFLICT SET 必须含三 cover 列
for col in cover_image_data cover_image_mime cover_source_url; do
grep -A 30 "_pullReadingNotes" \
lib/features/sync/data/repositories/sync_repository_impl.dart \
| grep -q "${col} = excluded.${col}" \
|| (echo "❌ ON CONFLICT SET missing ${col}"; exit 1)
done#6d last_opened_at RVH pull-only(v52 落地)
契约 → 根 CLAUDE.md §3「Merge 策略」:reading_pages.last_opened_at 取 MAX, RVH 无阅读功能,只 pull 不 push 此列。
RVH 落地:sync_repository_impl.dart::_pushReadingPages 的字段白名单内不含 last_opened_at (显式 mapped 字典,非 SELECT * 过滤),字典上方有 🛡️ 注释守门。pull 侧 MAX merge:
sql
last_opened_at = CASE
WHEN excluded.last_opened_at IS NULL THEN reading_pages.last_opened_at
WHEN reading_pages.last_opened_at IS NULL THEN excluded.last_opened_at
WHEN excluded.last_opened_at > reading_pages.last_opened_at
THEN excluded.last_opened_at
ELSE reading_pages.last_opened_at
ENDRFC3339 字典序 = 时间序(与 #5d watermark 同模式)。Graceful 降级:Supabase 端若未执行 ALTER TABLE user_reading_pages ADD COLUMN last_opened_at,pull 字段缺失得 null, CASE WHEN 自然保留本地 NULL,不报错。
🔒 本端独有的消费方与排序陷阱:2026-08-14 起阅读笔记列表页的「最近阅读」排序(默认)
- 列表项副标题相对时间消费该列,走
getLastOpenedAtByNote(SELECT note_id, MAX(last_opened_at) ... GROUP BY note_id,一次取全表避免列表 N+1)。 RVH 自采的笔记该字段恒为 NULL(本端无 touch 路径),排序上统一落到有值的之后、 组内保持updated_atDESC —— 实现是分组拼接而非整体 sort (DartList.sort不稳定,整体排会打乱「从未阅读」那组的次序)。 回归test/features/books/reading_note_list_state_test.dart。
历史:v52 落地时无 UI 消费方 —— 最初设计在 review 卡 SourceInfoBar 加「🖥 X 时间前」徽标, 2026-05-22 评审后认定 review 场景下该信息价值薄(用户不在阅读,时间戳与记忆无关), 删除 UI 渲染只保留数据层。
#6e reading_notes 删除必走软删,且必须手工级联软删子树(v57.1,RVH 先行)
契约 → src-tauri/CLAUDE.md §2 #6a(父行删除必须同事务显式软删整棵子树,禁靠 FK CASCADE)
- #6b(push payload 的
deleted_at传本地真值,禁恒写 null)+ #6c(pull 父行守卫三态)。 本条 RVH 先行,交接docs/cross-end/19-reading-notes-tombstone-handoff.md§4。
RVH 落地:
reading_note_datasource_impl.dart::deleteBook—— 单事务,笔记本体先行,noteRows == 0在事务内抛(异常回滚,不留「子表已软删、笔记没墓碑」的半截状态)- 8 处读查询加
deleted_at IS NULL sync_repository_impl.dart::_pushBooks—— dirty-check 加墓碑分支;payload 的deleted_at传本地真值sync_repository_impl.dart::_pullReadingNotes—— 三分支模板,分支①连带软删本地子树防对端漏传_pullReadingPages的bookExists守卫加AND deleted_at IS NULL
🔒 本端独有:部署顺序是硬约束,不可颠倒 —— ① Supabase ALTER → ② 双端 pull → ③ 双端 push。 ①最先是因为 PostgREST 对未知列返回 PGRST204,整批 push 失败;②可早于③(pull 读缺失字段得 null,无害); 反向部署会让「对端写墓碑而本端无视」变成真实数据问题。
已知遗留:本红线立项前双端删除产生的 Supabase 行 deleted_at 仍是 NULL = 活跃, 存量僵尸行需手工补墓碑。
守卫判据(⚠️ 见节首「谁在执行」):
bash
F=lib/features/reading_notes/data/datasources/reading_note_datasource_impl.dart
# 删除路径不得出现硬删
grep -qE "db\.delete\(\s*'reading_notes'" $F \
&& (echo "❌ #6e regression: reading_notes hard delete"; exit 1) || true
# push payload 必须带 deleted_at 真值(窗口 60:mapped 字典离函数头 ~41 行,留余量)
grep -A 60 "_pushBooks(Database" lib/features/sync/data/repositories/sync_repository_impl.dart \
| grep -q "'deleted_at': r\['deleted_at'\]" \
|| (echo "❌ #6e: push payload missing deleted_at real value"; exit 1)
# _pullReadingPages 的父行守卫必须存在且**不放行墓碑父行**。
# ⚠️ 2026-08-27 守卫改三态(#6f)后,原来那句
# grep -q "FROM reading_notes WHERE id = ? AND deleted_at IS NULL"
# 已失效 —— 现在的形状是 `_parentState(db, _kNoteParentSql, ...)`,判据挪进了常量。
grep -q "_parentState(db, _kNoteParentSql, r\['note_id'\])" \
lib/features/sync/data/repositories/sync_repository_impl.dart \
|| (echo "❌ #6e/#6f: _pullReadingPages 父笔记守卫丢了"; exit 1)#6f 墓碑父行不算存在:删除子树按父表算 + pull 父行必须三态(v61)
契约 → src-tauri/CLAUDE.md §2 #6a(写侧子树)+ #6c(读侧三态,含「压成两态的两种方向都是缺陷」 与「watermark 不回头 ⇒ 永久丢行」)。裁决单 docs/cross-end/34-rb-tombstone-parent-adjudication-handoff.md。
⚠️ 「软删子行必须 bump
updated_at」那一款归 #6g 的规则 W 统一管, 判据与 CI grep 以 #6g 为准,别在两处各写一份。
RVH 的落地形状:reading_pages.note_id 与 word_page_links.{learning_entry_id,reading_page_id} 三个父列都是 NOT NULL,所以没有 RB pull_reading_pages 那种「照落 + 置 NULL」的选项 —— 缺失 → skip(FK 落地限制)、墓碑 → skip 远端活行。两态动作相同但理由不同, 代码里仍显式分开写,好让「父列改可空时该分头处理」一眼可见。 远端墓碑行不经过守卫(各 _pullXxx 的墓碑三分支排在守卫之前),照常落地。
- 写侧:
notebook_datasource.dart::deleteNotebookEntry(词条 + links + cloze,单事务)·app_database.dart::prunePreinstalledVocabulary(v28 起就合规,可作模板)·reading_note_datasource_impl.dart::deleteBook(笔记子树,v61 补updated_atbump) - 读侧:
sync_repository_impl.dart::_parentState+_pullReadingPages/_pullWordPageLinks两处调用点
🔒 本端独有:撤销必须与删除对称(2026-08-27 真机验证补) restoreNotebookEntry(UI 上删除后 4 秒 SnackBar 的 Undo)必须在同一事务里把同一次删除带走的 子行还回来 —— 按 entry 的墓碑时间戳精确匹配(deleted_at = <stamp>),不是无条件 deleted_at IS NOT NULL(否则会错误复活更早因 deleteBook 等原因软删的行)。
🔴 这条是 #6f 落地当天漏掉的:删除侧改成带走子树后,撤销侧仍只清 entry 的
deleted_at⇒ Undo 之后「词回来了、但 6 条来源和 1 条语境永久没了」。用户看得见词回来、看不见来源没了, 属静默损失。#6f 之前删除不碰 links,撤销才恰好对称 —— 是本次改动新引入的不对称,不是既有缺陷。 RB 侧没有这条路径(其复活走save_word的ON CONFLICT DO UPDATE SET deleted_at = NULL), 所以 RB doc 34 §5 承诺的「删了又加回来,来源还在」在 RVH 得靠本方法自己兑现。
不适用的地方(判过了,别"顺手"补):
- 标「已认识」(
local_known_words_datasource.dart::addOrReviveUserWord)在 RVH 只写known_words,完全不碰learning_entries⇒ 不是墓碑路径,无子树义务。这与 RB 不同 (RB 的add_known_word会软删learning_entries),是产品语义差异不是遗漏。 日后若改成「标已认识即从生词本移除」,本条立刻适用。 word_cloze_contexts的 pull 无父行守卫 —— 该表无 FK、按word关联,RB 侧同样无条件 INSERT。 单方面加守卫会造成两端收敛规则分歧(#5g 的 reconcile 前提是两端同规则),故不加。
存量孤儿:写侧修复只管今后的删除。此前的存量孤儿仍在 Supabase 里,巡检 (supabase/sql/monitoring.sql §3b tombstone_parent,两端共用一份,RVH 不重复实现) 一上线就报 warn —— 那是真的,不是误报。清理由人执行、先备份,形状是打成墓碑 (deleted_at = now() + bump updated_at),不要 hard DELETE。 跨端并发的残余(一端删词、另一端同时存同一个词)是设计残余不是 bug, 写侧根治不了,兜底就是那条巡检 —— 别为它加更多写侧逻辑。
守卫判据(⚠️ 见节首「谁在执行」):
bash
F=lib/features/sync/data/repositories/sync_repository_impl.dart
ND=lib/features/vocabulary_notebook/data/datasources/notebook_datasource.dart
BD=lib/features/reading_notes/data/datasources/reading_note_datasource_impl.dart
# 规则 1:删词必须带走两样子行
grep -q "UPDATE word_page_links SET deleted_at = ?, updated_at = ?" $ND \
|| (echo "❌ #6f: deleteNotebookEntry 没带走 word_page_links"; exit 1)
grep -q "UPDATE word_cloze_contexts SET deleted_at = ?, updated_at = ?" $ND \
|| (echo "❌ #6f: deleteNotebookEntry 没带走 word_cloze_contexts"; exit 1)
# 规则 2:软删 word_page_links 的地方一律不许只写 deleted_at。
# ⚠️ 别用 `grep -c` 数总量 —— 数量会随重构漂移。直接查反例形状:
# `SET deleted_at = ?` 后面**不接** `, updated_at`(含跨行的 heredoc SQL)。
if grep -rn "UPDATE word_page_links SET deleted_at = ?$" $F $ND $BD \
|| grep -rn "UPDATE word_page_links SET deleted_at = ?\s*$" $F $ND $BD; then
echo "❌ #6f 规则 2: 软删 word_page_links 漏了 updated_at bump(回声环)"; exit 1
fi
# 规则 3:守卫必须走单点 _parentState,不许手搓回裸 COUNT(*)
grep -q "enum _ParentState { alive, tombstoned, missing }" $F \
|| (echo "❌ #6f: _ParentState 没了"; exit 1)
grep -q "_parentState(db, _kEntryParentSql, r\['learning_entry_id'\])" $F \
|| (echo "❌ #6f: _pullWordPageLinks 父词条守卫不是三态"; exit 1)
grep -q "_parentState(db, _kPageParentSql, r\['reading_page_id'\])" $F \
|| (echo "❌ #6f: _pullWordPageLinks 父页守卫不是三态"; exit 1)
# 裸 COUNT(*) 不许在 _pullWordPageLinks 里复活。
# ⚠️ 判据必须**限定在这个函数体内**:全文件搜 `SELECT COUNT(*) FROM learning_entries`
# 会误伤 _pullLearningEntries 里那处**合法**的「本地行存不存在」检查
# (它决定要不要写墓碑,不是父行守卫)——实测这条 grep 的第一版就是这么假红的。
if sed -n '/_pullWordPageLinks($/,/^ }$/p' $F \
| grep -v -E '^\s*//' | grep -q 'COUNT(\*)'; then
echo "❌ #6f: _pullWordPageLinks 里裸 COUNT(*) 父行守卫复活了"; exit 1
fi关联:RVH 回执 docs/cross-end/36-rvh-tombstone-parent-confirmation.md · 回归 test/features/sync/tombstone_parent_test.dart(10 用例,三处注入缺陷实证过)。
#6g 墓碑行的 synced_at 怎么写 —— W / P1 / P2 / P3 四条(v62)
契约 → 根 CLAUDE.md §4 #6d(两端同文;含四条子规则、NULL 陷阱、 「禁止 parse 成 DateTime 再比」、回声环症状)。契约单 docs/cross-end/38-rb-tombstone-synced-at-contract-handoff.md。
「脏」的定义两端同文(RVH 6 个 _pushXxx 的 WHERE / RB push.rs::USER_SCOPED_DIRTY):
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))RVH 落地(四条各自的单点):
| RVH 落点 | |
|---|---|
W 本端软删 deleted_at 与 updated_at 写同一个参数 | 全仓 11 处软删站点:notebook_datasource.dart::deleteNotebookEntry · reading_note_datasource_impl.dart::deleteBook · app_database.dart::prunePreinstalledVocabulary · local_known_words_datasource.dart::softRemoveForUser · cloze_pool_writer.dart · sync_repository_impl.dart::_reconcileClozePool |
P1 pull 墓碑分支的 synced_at 只由远端行自己的列拼出 | sync_repository_impl.dart::_remoteSyncTs 单点,回落链 updated_at → created_at → '',终点刻意是空串不是 now |
P2 synced_at = MAX(...),操作数不许为 NULL | 6 处墓碑分支的 synced_at = MAX(?, ?);Dart 侧算用 _maxIso(compareTo) |
| P3 push 成功后的 mark 也要「写完立刻 clean」 | sync_repository_impl.dart::_markSynced |
🔒 本端独有的两项证据(RB 侧没有,别删):
- 触发门槛实测 27 秒(2026-08-27 真机反算,见
docs/cross-end/39-rvh-tombstone-synced-at-confirmation.md§5.3)。 ⚠️ 2026-08-28(T3-8)起根 §4 #6d 已把这个数并进去,那边现在两句都在(「几秒」标注为按 autoSync 60s 估的理论最坏值、27 秒为真机实测)。本条留在这里的是反算过程,不是那个数字本身。 - 丢 P3 在 RVH 是真实可达的,RB 侧只是防御性 —— 拉回来的墓碑一旦再次变脏,重推后永久留脏。
真机验证(2026-08-27,Android 24094RAD4C + 真实 Supabase):把一条已是墓碑的 user_word_page_links 行的 deleted_at 改成 2099-01-01(等价于「对端时钟远快于本端」, 比改设备时钟安全 —— 该行本就死的,且 adb 无 root 改不了时钟)。该行远端 updated_at 恰好是 NULL ⇒ 一次同时覆盖 P1 / P2 / NULL 陷阱:
- 判据分岔:本地
synced_at落成2099-01-01(取自远端行)。旧代码会写本端now≈2026-08-27T06:4x⇒deleted_at > synced_at⇒ DIRTY ⇒ 进环 - 症状级:
server_updated_at在 8+ 轮 autoSync / 9 分钟里逐字节不变 ⇒ 一次都没重推 - 🔑 防假绿的对照:同期
last_sync_attempt_at每 60s 推进 —— 否则「没重推」可能只是 「压根没同步」。这个对照必须做
存量数据不需要 backfill:已写歪的行正在回声环里,而环本身保证它们每轮都会被服务端重新发回来 ⇒ 修复上线后第一轮 sync 就走进修好的墓碑分支而自愈。没在环里的行 = 没写歪 = 不用管。
守卫判据(⚠️ 见节首「谁在执行」):
bash
F=lib/features/sync/data/repositories/sync_repository_impl.dart
# P1:回落链单点必须在,且不许出现本端时钟
grep -q "String _remoteSyncTs(Map<String, dynamic> r) =>" $F \
|| (echo "❌ #6g P1: _remoteSyncTs 单点没了"; exit 1)
# ⚠️ 排除注释行 —— 否则会被「解释为什么不能用 now」的注释本身误伤(同 #5d / #5g)
if sed -n '/String _remoteSyncTs/,/^$/p' $F | grep -v -E '^\s*(//|///)' | grep -q 'now'; then
echo "❌ #6g P1: _remoteSyncTs 的回落链里出现了本端时钟"; exit 1
fi
# P2:6 处墓碑分支必须都走 MAX(数量是硬断言 —— 加第 7 张同步表而漏改会红)
n=$(grep -c 'synced_at = MAX(?, ?)' $F)
[ "$n" = "6" ] || (echo "❌ #6g P2: 墓碑分支 MAX 数量 = $n,应为 6"; exit 1)
# P3:_markSynced 必须 MAX + 双 COALESCE(缺任一个都会踩 NULL 陷阱)
sed -n '/Future<void> _markSynced/,/^ }$/p' $F \
| grep -q "MAX(COALESCE(updated_at, ?), COALESCE(deleted_at, ''))" \
|| (echo "❌ #6g P3: _markSynced 不是 MAX + 双 COALESCE"; exit 1)
# §4.2:禁止在 synced_at 的计算里 parse 成 DateTime
if grep -n 'DateTime.parse' $F | grep -iE 'synced|deleted|maxIso'; then
echo "❌ #6g: synced_at 的计算里出现 DateTime.parse(时间序 ≠ 字符串序)"; exit 1
fi
# W:本端软删站点不许只写 deleted_at(全仓,不限于 sync 文件)
if grep -rn "SET deleted_at = ?[,)]*$" lib --include="*.dart" \
| grep -v 'updated_at'; then
echo "❌ #6g W: 有本端软删站点没同时写 updated_at"; exit 1
fi关联:RVH 请求 docs/cross-end/37-rvh-tombstone-synced-at-handoff.md · 回执 docs/cross-end/39-rvh-tombstone-synced-at-confirmation.md · 回归 test/features/sync/tombstone_synced_at_test.dart(11 用例,五处注入缺陷实证)。
#6h next_review_date 必须带 UTC 标识符 —— 写侧与读侧一起(v63)
编号是 RVH 本地的。 RB 侧尚未立成编号条款,只记在
docs/verification/learning-loop.md§2.4 与docs/cross-end/40-rb-learning-loop-handoff.md§3.1。 跨仓引用请写「RB doc 40 §3.1」而不是「#6h」。
规则(三条,缺一即为缺陷):
- 写侧 —— 凡产出
learning_entries.next_review_date的时钟,一律nowUtc()/DateTime.utc(...), 禁止裸DateTime.now()与DateTime(...)字面构造。哨兵走单点常量kNeverReviewedSentinel(声明在notebook_datasource.dart)。 - 读侧 —— 任何作为 SQL 参数去和该列比较的时间串,必须同样带 UTC 后缀(
nowUtcIso(), 或对已有本地锚点调.toUtc().toIso8601String()—— 换表示、不动时刻)。 - 判据是「有没有后缀」,不是「是不是 UTC」 —— 一个无后缀的串无法判断是哪个时刻,那就是问题本身。
理由:Dart 的 toIso8601String() 对 local DateTime 不输出时区后缀(offset 恰为 0 的 local 也不输出), 裸串经 push 原样直达 Supabase。两端的到期判定(RB get_due_cards / RVH getDueReviews) 与 merge 判据(RB common.rs::should_take_remote_srs / RVH _pullNotebookEntries)都是字符串比较 ⇒ UTC+8 下卡片晚 8 小时到期、且本端在 next_review 这一维系统性占优;负时区下方向翻转。
🔴 单端自测永远看不出来:同一台机器写、同一台机器读,DateTime.parse 把裸串按本机时区 解回同一个时刻,本地自洽。RB 的六道既有守卫(SM-2 三端黄金向量 / sync-verify / sync 单测) 在同一批真实数据面前全绿 —— 没有一道在问「算出来的那个时刻是哪个时刻」。
⚠️ 两个反向 footgun(都实测踩过):
- 只修写侧会新造一个反向缺陷。 写侧裸串与读侧裸串此前互相抵消(对本端自写的行碰巧正确); 写侧改 UTC 而读侧不改 ⇒ due 统计从「碰巧对」变成「早 8 小时」。三处必须同批改。
- 「给裸串补个
Z」能把形状检查骗绿而排期依然错一个时区。 所以形状与语义 必须是两条独立断言(RB M1/M3 的分法)。
实现:sm2_algorithm.dart::getNextReviewDate(nowUtc())· notebook_datasource.dart::kNeverReviewedSentinel(DateTime.utc(1970,1,1),加词与复活共用)· statistics_repository_impl.dart 三处绑定串。
守卫:test/features/vocabulary_notebook/next_review_utc_test.dart (L-A 落库形状 / L-A0 判据自检 / L-B 语义 + 跨时区合成向量 / L-C 写侧结构 / L-D 读侧结构,八处注入实证)。 ⚠️ L-B 不能替代 RB 的 M3 —— 进程内自写数据必然自洽,M3 读的是两端真实数据、它才是最终判据。
关联:RVH 回执 docs/cross-end/42-rvh-learning-loop-confirmation.md。
#6i 分支②「本地已删 + remote 活」的 skip 必须以「墓碑仍待推」为条件(2026-08-31)
契约 → 根 CLAUDE.md §4 RB #6e(两端同文;含「判据是因果的不是时序的」、 「远端赢的理由是可恢复性不对称」、以及为什么不加 revived_at 意图列)。 裁决单 docs/cross-end/47-rb-tombstone-vs-remote-alive-adjudication.md · 本端回执 docs/cross-end/49-rvh-tombstone-vs-remote-alive-confirmation.md。 🔒 跨仓引用带仓名:本条 = RVH #6i ↔ RB #6e(RVH 的 #6e 是 reading_notes 软删级联,别撞)。
RVH 落地(四个单点,缺一即漂):
| RVH 落点 | |
|---|---|
| 判据 | sync_repository_impl.dart::_tombstonePendingPush —— SQL 由 _kUserScopedDirty 拼出,禁止手抄 |
| 判据 + 复活 + warn | _resolveTombstoneVsRemoteAlive(返回 keepSkipping / revived)—— 复活语句因此只有一处,G2 才数得动 |
| 调用形状 | _keepSkippingTombstone,6 处分支② 逐字同形:if (localDeletedAt != null) { if (await _keepSkippingTombstone(...)) continue; total++; },revived 时落到既有的双活逻辑,不复制第二份 merge |
| 复活写法 | SET deleted_at = NULL, updated_at = ?, synced_at = ?,两个绑参都是 _remoteSyncTs(r)(#6g P1 禁本端时钟 + P3 写完立刻不脏) |
🔒 本端独有:_pullKnownWords 必须先补三分支(RVH-0,裁决单 §5.1)
本表从前压根没有分支② —— 远端活行无条件走 ON CONFLICT(id) DO UPDATE SET … synced_at = <本端 now>。 本地行是墓碑时它不清 deleted_at(符合 #6b),却把 synced_at 刷成本端 now ⇒ deleted_at < synced_at ⇒ 墓碑变 clean 却从未上行 = 已认识词的删除静默丢失。 它同时破坏 #6i 的前提(clean ⇒ 服务端见过这条墓碑),所以必须先补、再落 ②b。
⚠️ 触发条件要写准,否则测不出来:正常轮次里 push 排在 pull 之前,墓碑当轮就上行了,够不到这条。 真会咬的是两种 —— ① 本条红线要治的分歧场景本身;② 该轮 push 失败或被 _batchLimit 推迟、 而同一轮 pull 又恰好把这行带了下来:一次网络抖动就足以永久吃掉一次删除。 T7 的夹具用的是 ②(failPush),别用「push 正常成功」的顺序场景。
🔒 本端独有:word_page_links 的 push 口径本轮真的变了行为
抽 _kUserScopedDirty 之前,本表那份脏检查整支 updated_at 条件都不在。于是 restoreNotebookEntry(删词 4 秒内的 Undo)把 link 复活成 deleted_at = NULL, updated_at = now 之后,该行不脏、永不上行 —— 本端词回来了,云端与对端那条 link 还是墓碑, 且任何后续操作都修不了(复活幂等,不会再让它变脏)。收敛成统一谓词顺带堵掉了这条。
一处刻意保留的次序(不是遗漏):_pullWordPageLinks / _pullReadingPages 的复活排在 父行三态守卫之前(与 RB 逐字同序)。父行是墓碑时,被复活的子行会立刻被守卫 skip 掉, 本地留下一条挂在墓碑父下的活行(#6f 服务端巡检 tombstone_parent 会报它)。 常见路径上父词条本轮更早就被同一条红线复活了(pull 顺序:learning_entries → word_page_links), 故不为它单方面改序 —— 两端次序一致比本端局部更优先,改序要两端一起改(回执 §5 已登记)。
守卫判据:test/features/sync/tombstone_remote_alive_test.dart(21 用例,13 处注入实证)。 G1 见上面 #5h 的换型表;另有:
| 断言 | 注入什么会让它红 | |
|---|---|---|
| G2 | await _keepSkippingTombstone( 恰好 6 次(每张同步表一处);deleted_at = NULL 全文件恰好 1 次 | 漏改一张表(那张表继续永久分歧,静默)/ 多出一处裸复活(绕开判据,把本端未推的删除当场撤销) |
| G3 | ON CONFLICT … DO UPDATE SET 仍不许写 deleted_at(#6b 照旧管着 upsert 那条路) | 把 ②b 写成 ON CONFLICT … SET deleted_at = NULL |
| G4 | 复活语句里不出现 now / DateTime.,且必须含 synced_at = ? | 掺本端时钟(#6g P1)/ 漏写 synced_at(#6g P3) |
🔒 本端独有:那句 total++ 不是装饰(2026-08-31 真机补)
watermark 的推进闸门是 totalPushed > 0 || totalPulled > 0,它刻意保守 —— 「pull 返回了、但全被守卫 skip」的行若推进水位就永久丢失(见 _ParentState 文档)。 ②b 复活了行却不计数 ⇒ 闸门不开 ⇒ 水位不动 ⇒ 下一轮把同一批重新拉一遍。 真机实测该行连拉三轮空转,最后靠行数对账的全量自愈才顶过去。 ⚠️ 下游的双活逻辑可能对同一行再计一次(六张表里四张会)—— count 的三个消费者对多计一行 都不敏感,而少计会让水位卡住,两害相权宁可多计。
🔴 三条只有做过才知道的坑(前两条 RB 侧栽过、本轮实测复现):
- 「复活后立刻不脏」那条断言只在一个时间戳方向上有鉴别力。 远端 ts 早于本地墓碑时, 「复活漏写
synced_at」注进去之后updated_at > synced_at恰好为假 ⇒ 照样全绿。 本轮注入实测:no-synced-at只让T4(later)变红、T4(earlier)绿着。 一条只在时间戳方向凑巧时才成立的断言等于没有断言 —— 两个方向都要跑。 - T4/T5 的探针必须选
word_page_links。 它的分支② 之后是INSERT OR IGNORE(行已存在 ⇒ 整条 no-op), 复活写下的值原样可验;换成 cloze / learning_entries,后续的双活逻辑会覆盖synced_at(分别写syncTs/ 本端now),断言就测不到复活语句本身了。 - T8(②b 计入 count)的探针恰好相反,必须选
word_cloze_contexts。 只有它与learning_entries的「本地更胜 / 无 gloss 变化」分支不计数;其余四张下游会顺手把 count 补上 ⇒ 换成它们这条断言恒绿。实测对照:去掉 cloze 的total++⇒ T8 红; 去掉reading_notes的 ⇒ 21 例全绿。这条对照组留在注入清单里,防止后人顺手换探针。
与 cap-5 池(#5g)的相互作用 —— 已判,别加特例:②b 在 cloze 上复活一行后活跃行可能变 6, 收尾的 _reconcileClozePool 会按既有规则(created_at ASC, id ASC 留新删旧)重新封顶, 被复活的行有可能当场又被淘汰。这是 cap-5 的既定语义,且与「该行经分支③ 全新插入」结局完全相同; 关键是它收敛(那次淘汰是一条新的、脏的墓碑,会 push 上行,对端经分支① 落地)。
跨端契约镜像 backlog(2026-04-29 镜像审计的余项)
🔒 本节是「RVH 对 RB 红线的对齐状态」的唯一真相源(2026-08-29 T4-4 定死)。 别处若出现「RVH 自曝 N 处违反」这类计数,一律以本节为准 —— 那种计数会腐烂: 2026-08-29 实测
scripts/cross-end-check.sh的人工 checklist 与docs/verification/learning-loop.mdK7 都还写着 4 处,而 #7 / #9 早在 2026-08-28 就修完了。 两处已改为指向本节。
RB 已立、RVH 尚未完全对齐的红线(当前 2 条:#2 · #6;#7 / #9 已修,保留记录见下):
- RB 红线 #2(SQLite 禁
LOWER(),用COLLATE NOCASE)— RVH 多处违反 (notebook_datasource.dart:392等),全局替换待做 - RB 红线 #6(soft-delete 不 hard DELETE)—
auth_repository_impl.dart的clearLearningDataForUser在用户切换时 hard DELETE 5 张表(known_words 按 user_id 过滤; 其他 4 张无条件全清)。当前产品定位「单设备单用户」可接受, 多账号本地共存功能上线前必须重审 - ✅ RB 红线 #7(
save_word须重新激活 soft-deleted 条目)— 2026-08-28 已修(v63)。 原来的表现不是「少复活一次」而是加词直接失败:isWordInNotebook带deleted_at IS NULL看不见软删行 → 走 INSERT → 撞UNIQUE(user_id, word COLLATE NOCASE)⇒ 删过的词从此再也加不回生词本。修法逐字段镜像 RBcrud.rs::save_word: 新增NotebookDatasource.reviveSoftDeletedEntry,清deleted_at+ SM-2 归零 + 落哨兵 + bumpupdated_at, 复用同一行 id、不复活子树。⚠️ 与restoreNotebookEntry(4 秒 Undo,#6f 按墓碑时间戳还子树) 语义相反,把其中一条实现成另一条是这里最容易犯的错。 回归test/features/vocabulary_notebook/redline7_revive_on_add_test.dart(R7-1…R7-5,五处注入实证) - ✅ RB 红线 #9(写
word字段前必经 normalize)— 2026-08-28 键空间规则落地 (裁定见 RB doc 43,RVH 回执docs/cross-end/46-rvh-key-space-landing-confirmation.md)。- 🔑 规则不是「一律归一」也不是「一律不归一」,而是「解析成一个真实存在的
vocabulary行」—— 因为vocabulary里同时住着 lemma 行和非 lemma 行(bear/bearing、people/person都是行):🔒 第三步必须是整串w = trim + lowercase + NFC(input) # 无条件(红线 #9 这半截照旧) if EXISTS(vocabulary WHERE word = w): return w # 原形本身就是一行 → 用它 return normalize(w) # 否则整串归一normalize而非逐 token 的normalizePhrase(后者改写 46 个多词条目的键); 第一步必须是Lemmatizer.normalizeCaseOnly(换成整串normalize会让 EXISTS 只在 lemma 空间里问、整条规则失效)。 - 判据单点
lib/shared/data/database/word_key.dart:canonicalWord(db, input)(输入是 已从 DB 取出的vocabulary.word)+surfaceWord(input)(输入是页面 surface)。 🔴 两者不能合并 —— 同一个串从两条路来意思不同,而字符串本身不携带这个信息、只有调用方知道。 RVH 当前所有调用方传的都是vocabulary.word(OCR 管线不写 known_words,只当读侧过滤器), 故本端暂不需要分流;RB 需要(多一条 content-script 查词弹窗的 surface 腿)。 ⚠️ 这个判断的载体是守卫 K4 的调用方名单,新增调用方即红。surfaceWord当前零调用方但刻意保留 —— 删掉它等于把「这里需要分流」从代码里抹掉。 - 🔴 写、读、删三侧必须共用单点。只改写+读的后果是**「加得进、删不掉」**且不报错 (写入存 A、删除按裸
toLowerCase找 B)—— 这一处是守卫 K2 当场抓出来的,不是想起来的。 - 存量不需要回填(此前裸
toLowerCase+ 调用方全传vocabulary.word⇒ 恰好等价于 EXISTS 分支恒命中)。 - 守卫:
test/features/known_words/key_space_guard_test.dart(K0–K5,7 处注入实证)+redline9_writer_guard_test.dart。⚠️ 第三步那条做成源码层断言而非行为断言 —— 行为用例验不到它(测试库里dining room本身就是一行,EXISTS 直接返回、第三步根本没执行)。
- 🔑 规则不是「一律归一」也不是「一律不归一」,而是「解析成一个真实存在的