主题
19 — reading_notes 删除墓碑:RVH 已落地 + RB 侧待办交接
物理位置:本文件在 RVH 仓库
~/reading_vocab_helper/docs/cross-end/19-reading-notes-tombstone-handoff.md。 本次 RVH 先行,RB 侧待镜像(与 16/17 的方向相反)。创建:2026-08-03 · 关联 RVH schema v57.1(ALTER-ADD,
_schemaVersion仍 57) 起因:2026-08-03 会话验证封面流程时顺带发现 RVH↔RB 笔记删除不同步。
0. 一句话结论
reading_notes 是 6 张跨端同步表里唯一没有墓碑列的表,双端均走硬删、Supabase 也无 deleted_at 列 —— 删除信号在整条链路上无处承载。这不是 RVH 单边缺陷,是双端对称的空洞, 即 RB v23「笔记四级删除闭环」明确排除在外的第四级。RVH 已补齐本端全部环节;RB 侧需镜像, 否则 RB 发起的删除仍然上不去。
1. 实测证据(全部 grep 过,非推断)
| 环节 | 位置 | 现状 |
|---|---|---|
| Supabase DDL | ~/reading-browser/supabase/sql/sync-tables.sql:134-149 | user_reading_notes 无 deleted_at |
| RB 删除 | src-tauri/src/commands/notes.rs:521 | 裸 DELETE FROM reading_notes |
| RB push | src-tauri/src/commands/sync/push.rs:143-156 | payload 无 deleted_at |
| RB pull | src-tauri/src/commands/sync/pull.rs:310-326 | RemoteReadingNote 不反序列化 deleted_at;:348 起无条件 upsert |
| RB 本地 schema | src-tauri/src/db/terminal_schema_baseline.txt:10 | reading_notes 无 deleted_at(同表 reading_pages/word_page_links 有) |
| RVH 删除 | lib/features/reading_notes/data/datasources/reading_note_datasource_impl.dart:104 | 裸 db.delete(本次已改) |
| RVH push | sync_repository_impl.dart::_pushBooks | 取数无墓碑分支(本次已改) |
| RVH pull | sync_repository_impl.dart::_pullReadingNotes | 无条件 upsert(本次已改) |
RB CLAUDE.md:238 自己记录了这个缺口:
soft-delete 墓碑传播覆盖:learning_entries / known_words / … / word_page_links + reading_pages(v23)/ word_cloze_contexts(v24)。reading_notes 仍走硬删(
delete_reading_noteFK CASCADE,不传播)
⚠️ 注意 RB CLAUDE.md:366「10 张用户表 + RLS + deleted_at」是对整组表的笼统概括,逐表 DDL 不成立, 排查时容易被它误导。以 sync-tables.sql 的实际 DDL 为准。
2. 故障模型
任一端删 → 本地硬删(无墓碑)→ push 取不到行 → 云端行永生 → 对端无条件 upsert破口 A — 删除永不上行:任一端删,对端那条笔记继续存在。双端对称。
破口 B — 删除端自己会被复活:云端活行 + 无条件 upsert。这条是潜伏的,因为红线 #5d 的 pull filter 是 server_updated_at > watermark,刚删那条行的 server_updated_at 早于当前 watermark,不在批次里。触发条件:
- 对端 touch 该行(改标题 / 封面 / 阅读进度 →
server_updated_at刷新 → 进批次)— 最现实 server_updated_at_migration_v1兜底全量 pull- 换设备 / 重装(watermark 为 null → 全量)
2026-08-03 实测的「RVH 删掉已 push 的 CleanupVerify,多轮 60s 自动同步没复活」正是被条件 1-3 都没满足掩盖了,不是没有问题。排查时需主动构造上述条件。
破口 C — CASCADE 抹掉子表墓碑:两端本地 FK 均为 ON DELETE CASCADE (RVH 实测 PRAGMA foreign_keys = ON,app_database.dart:315):
reading_notes ←CASCADE─ reading_pages ←CASCADE─ word_page_links硬删父行会物理删除子表行,把 v23/v55 刚给这两张表加的 deleted_at 墓碑一并抹掉 → 云端子行同样永生。
现有的两处 FK 存在性守卫(RVH _pullReadingPages 的 bookExists、_pullWordPageLinks 的双 FK 检查) 会暂时挡住子树回流,但那是用破口 A 掩盖破口 B —— 一旦父行复活,同一轮 sync(顺序为 FK 父表优先) 里子表守卫立刻通过,整棵子树连带复活。
3. RVH 侧已完成清单
3.1 schema(ALTER-ADD,不升 _schemaVersion)
- [x]
assets/sql/01_create_tables.sql:reading_notes加deleted_at TEXT+ 头部 v57.1 说明 - [x]
app_database._ensureLatestColumns:加幂等ALTER TABLE reading_notes ADD COLUMN deleted_at TEXT - [x]
_schemaVersion保持 57 —— 沿用 RB v23/v24 的 ALTER-ADD precedent,避免开发期删库重建丢本地数据 - [x]
docs/database/schema.md加 v57.1 变更摘要
3.2 删除路径改软删
- [x]
deleteBook改单事务软删三层:word_page_links(按 note 的 pages 反查)→reading_pages→reading_notes - [x] 不依赖 FK CASCADE(只在硬删触发,且会抹掉子表墓碑 —— 这正是 RB v23 决定两张子表必须同时补墓碑列的原因)
- [x]
learning_entries不动:词仍在生词本,只失去这个出处(镜像 RBremove_reading_page既定语义)
3.3 读查询加过滤(8 处)
- [x]
reading_note_datasource_impl:getBookById/getAllBooks/updateBook/searchBooks/bookExists - [x]
statistics_repository_impl:559(recent_book_name 子查询) - [x]
reading_page_datasource:496(getSourceStatsByBook) - [x]
notebook_datasource:805(book summaries CTE 查询)
3.4 sync 双向
- [x]
_pushBooks:dirty-check 加OR (deleted_at IS NOT NULL AND (synced_at IS NULL OR deleted_at > synced_at));payload 加deleted_at(传真值,不是恒 null);仍不传 onConflict(仅 PK 冲突,红线 #5f);user_id照旧填_userId(红线 #5b) - [x]
_pullReadingNotes:三分支墓碑(模板 =_pullReadingPages)。分支①连带软删本地子树(防御对端漏传)。保持ON CONFLICT(id) DO UPDATE(红线 #6b)、SET 子句保留 cover 三字段(红线 #6c)、SET 子句不写deleted_at - [x]
_pullReadingPages的bookExists守卫加AND deleted_at IS NULL(软删后行仍在,裸COUNT(*)会放行远端活页重建子树) - [x]
getSyncStatus的 pendingCount 加 reading_notes 墓碑分支(与 push 语义对齐)
4. RB 侧待办
本次会话未修改 RB 仓任何运行时代码。以下为需 RB 会话镜像的清单。
- 本地 schema:
reading_notesALTER ADDdeleted_at TEXT(沿 v23/v24 ALTER-ADD precedent,不动 baseline);同步更新terminal_schema_baseline.txtgolden 单测 notes.rs::delete_reading_note:硬删改单事务软删「笔记 + 其reading_pages+ 其word_page_links」。 现有UNCATEGORIZED_TITLE保护逻辑保留。可直接复用remove_reading_page的既有事务写法push.rs::push_reading_notes:SELECT 加deleted_at列 + 脏检查加墓碑分支;payload 加"deleted_at"(传row.get::<_, Option<String>>(..)真值,不能恒 None)pull.rs:RemoteReadingNote加#[serde(default)] deleted_at: Option<String>;pull_reading_notes加三分支墓碑处理(模板 = 同文件pull_reading_sources:458-470);ON CONFLICT SET 子句保持不含deleted_at- 所有
reading_notes读查询加WHERE deleted_at IS NULL(红线 #6) pull_reading_sources的父行存在性检查(若有)同样要加AND deleted_at IS NULLclear_user_learning_data(signout 清理) 按既有约定处理该列
5. 部署顺序(硬约束,不可颠倒)
① Supabase ALTER → ② 双端 pull 侧上线 → ③ 双端 push 侧上线① 必须最先:PostgREST 对 payload 里的未知列返回 PGRST204,整批 push 直接失败。 同 RB v23 给 user_reading_pages / user_word_page_links 加列时的部署纪律。
② 可安全早于 ③:pull 读缺失字段得 null,自然走活行路径,无害。
反向部署会造成真实数据问题:远端有列、RB 开始写墓碑,而对端还在无视 deleted_at —— 破口 B 就从「潜伏」变成「每轮 pull 都在刷新一条本该消失的笔记」。
DDL(RVH 仓已备):supabase/migrations/20260803_v57_1_reading_notes_deleted_at.sql
sql
ALTER TABLE user_reading_notes ADD COLUMN IF NOT EXISTS deleted_at TEXT;不需要 backfill(既有行 NULL = 活跃,语义正确)。不需要动 trigger(set_server_updated_at 是 BEFORE INSERT OR UPDATE FOR EACH ROW,软删走 UPDATE 路径,server_updated_at 自动刷新 → 墓碑正常进对端 pull 批次)。
6. 联调验收清单
前置:Supabase ALTER 已执行;双端均已上线新版本。
| # | 场景 | 预期 |
|---|---|---|
| 1 | RVH 删笔记 → 同步 → RB 同步 | RB 侧该笔记消失,其来源页 + 词关联一并消失,词条仍在生词本 |
| 2 | RB 删笔记 → 同步 → RVH 同步 | RVH 侧同上 |
| 3 | 场景 1 后,RVH 清 watermark(或重装)做全量 pull | 该笔记不复活(分支③:本地无 + remote 删 → 跳过不落墓碑) |
| 4 | 场景 1 后,RB 端 touch 同一 note_id 的其它字段 | RVH 不复活(分支②/③);RB 端本就该行已删,touch 路径应查不到 |
| 5 | 双端并发删同一笔记 | 幂等收敛,无 23505 / 409 |
| 6 | 删除后新建同名笔记 | 新 UUID,与墓碑行互不干扰 |
| 7 | RVH 删笔记后立刻看统计页 / 书架页 / 生词本分组 | 均不出现该笔记(8 处查询过滤生效) |
7. 遗留 / 不在本次范围
ocr_word_positions(本地专属,不同步)FK CASCADE 挂在reading_pages上,页改软删后其行不再被清理。 查询均经reading_pagesjoin 且带deleted_at IS NULL,不会显示;仅本地行数轻微堆积。 与 v55 页级软删的既有状态一致,未新增问题。word_cloze_contexts是否随笔记级删除连带软删:RBremove_reading_page在页级删除时按source_urlbest-effort 清理。笔记级是否比照办理由 RB 决定 —— RVH 无本地采集路径(红线 #5g pull-only), 且 RVH 侧source_url匹配不可靠,故 RVH 不发起这一步。—— v58 已修:clearLearningDataForUser的 watermark 清理写错了 storeauth_repository_impl.dart:238清的是 SharedPreferences 的last_sync_at(全仓无任何写入方 → 纯 no-op),而 watermark 实际存于app_metadata表(sync_repository_impl.dart::_setLastSyncAt)。 修法不是「改成清对地方」,而是把 watermark 改成 per-user key(last_sync_at:<userId>, 见lib/core/constants/sync_metadata_keys.dart):到来的用户读自己的 key(不存在 → 全量 pull), auth 侧清库删的是离开用户的 key —— 读写碰不同 key,因此不依赖「清理先于新用户首次 sync」的时序。 选 per-user 而非「清对地方」的理由:auth_repository_impl.dart:39的_controller.add(user)在:42的await _detectUserSwitch之前,新用户身份先广播 → autoSync 的首次 sync 与清库事务并发, 「清全局单值」方案的正确性取决于赢得这场竞争。回归测试见test/features/sync/watermark_per_user_test.dart。insertBook用ConflictAlgorithm.replace(reading_note_datasource_impl.dart:19):父表上的 INSERT OR REPLACE,同 id 重复插入会触发 CASCADE 抹子表。当前只在新建(新 UUID)路径调用,暂不触发; 属红线 #6b 精神的潜在 footgun,待单独评估。