Skip to content

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-149user_reading_notes deleted_at
RB 删除src-tauri/src/commands/notes.rs:521DELETE FROM reading_notes
RB pushsrc-tauri/src/commands/sync/push.rs:143-156payload deleted_at
RB pullsrc-tauri/src/commands/sync/pull.rs:310-326RemoteReadingNote 不反序列化 deleted_at;:348 起无条件 upsert
RB 本地 schemasrc-tauri/src/db/terminal_schema_baseline.txt:10reading_notes deleted_at(同表 reading_pages/word_page_links 有)
RVH 删除lib/features/reading_notes/data/datasources/reading_note_datasource_impl.dart:104db.delete(本次已改)
RVH pushsync_repository_impl.dart::_pushBooks取数无墓碑分支(本次已改)
RVH pullsync_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_note FK 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,不在批次里。触发条件:

  1. 对端 touch 该行(改标题 / 封面 / 阅读进度 → server_updated_at 刷新 → 进批次)— 最现实
  2. server_updated_at_migration_v1 兜底全量 pull
  3. 换设备 / 重装(watermark 为 null → 全量)

2026-08-03 实测的「RVH 删掉已 push 的 CleanupVerify,多轮 60s 自动同步没复活」正是被条件 1-3 都没满足掩盖了,不是没有问题。排查时需主动构造上述条件。

破口 C — CASCADE 抹掉子表墓碑:两端本地 FK 均为 ON DELETE CASCADE (RVH 实测 PRAGMA foreign_keys = ONapp_database.dart:315):

reading_notes ←CASCADE─ reading_pages ←CASCADE─ word_page_links

硬删父行会物理删除子表行,把 v23/v55 刚给这两张表加的 deleted_at 墓碑一并抹掉 → 云端子行同样永生。

现有的两处 FK 存在性守卫(RVH _pullReadingPagesbookExists_pullWordPageLinks 的双 FK 检查) 会暂时挡住子树回流,但那是用破口 A 掩盖破口 B —— 一旦父行复活,同一轮 sync(顺序为 FK 父表优先) 里子表守卫立刻通过,整棵子树连带复活。


3. RVH 侧已完成清单

3.1 schema(ALTER-ADD,不升 _schemaVersion

  • [x] assets/sql/01_create_tables.sqlreading_notesdeleted_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_pagesreading_notes
  • [x] 不依赖 FK CASCADE(只在硬删触发,且会抹掉子表墓碑 —— 这正是 RB v23 决定两张子表必须同时补墓碑列的原因)
  • [x] learning_entries 不动:词仍在生词本,只失去这个出处(镜像 RB remove_reading_page 既定语义)

3.3 读查询加过滤(8 处)

  • [x] reading_note_datasource_implgetBookById / getAllBooks / updateBook / searchBooks / bookExists
  • [x] statistics_repository_impl:559(recent_book_name 子查询)
  • [x] reading_page_datasource:496getSourceStatsByBook
  • [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] _pullReadingPagesbookExists 守卫加 AND deleted_at IS NULL(软删后行仍在,裸 COUNT(*) 会放行远端活页重建子树)
  • [x] getSyncStatus 的 pendingCount 加 reading_notes 墓碑分支(与 push 语义对齐)

4. RB 侧待办

本次会话未修改 RB 仓任何运行时代码。以下为需 RB 会话镜像的清单。

  1. 本地 schemareading_notes ALTER ADD deleted_at TEXT(沿 v23/v24 ALTER-ADD precedent,不动 baseline);同步更新 terminal_schema_baseline.txt golden 单测
  2. notes.rs::delete_reading_note:硬删改单事务软删「笔记 + 其 reading_pages + 其 word_page_links」。 现有 UNCATEGORIZED_TITLE 保护逻辑保留。可直接复用 remove_reading_page 的既有事务写法
  3. push.rs::push_reading_notes:SELECT 加 deleted_at 列 + 脏检查加墓碑分支;payload 加 "deleted_at"row.get::<_, Option<String>>(..) 真值,不能恒 None
  4. pull.rsRemoteReadingNote#[serde(default)] deleted_at: Option<String>pull_reading_notes 加三分支墓碑处理(模板 = 同文件 pull_reading_sources:458-470);ON CONFLICT SET 子句保持不含 deleted_at
  5. 所有 reading_notes 读查询加 WHERE deleted_at IS NULL(红线 #6)
  6. pull_reading_sources 的父行存在性检查(若有)同样要加 AND deleted_at IS NULL
  7. clear_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_atBEFORE INSERT OR UPDATE FOR EACH ROW,软删走 UPDATE 路径,server_updated_at 自动刷新 → 墓碑正常进对端 pull 批次)。


6. 联调验收清单

前置:Supabase ALTER 已执行;双端均已上线新版本。

#场景预期
1RVH 删笔记 → 同步 → RB 同步RB 侧该笔记消失,其来源页 + 词关联一并消失,词条仍在生词本
2RB 删笔记 → 同步 → RVH 同步RVH 侧同上
3场景 1 后,RVH 清 watermark(或重装)做全量 pull该笔记不复活(分支③:本地无 + remote 删 → 跳过不落墓碑)
4场景 1 后,RB 端 touch 同一 note_id 的其它字段RVH 不复活(分支②/③);RB 端本就该行已删,touch 路径应查不到
5双端并发删同一笔记幂等收敛,无 23505 / 409
6删除后新建同名笔记新 UUID,与墓碑行互不干扰
7RVH 删笔记后立刻看统计页 / 书架页 / 生词本分组均不出现该笔记(8 处查询过滤生效)

7. 遗留 / 不在本次范围

  • ocr_word_positions(本地专属,不同步)FK CASCADE 挂在 reading_pages 上,页改软删后其行不再被清理。 查询均经 reading_pages join 且带 deleted_at IS NULL,不会显示;仅本地行数轻微堆积。 与 v55 页级软删的既有状态一致,未新增问题。
  • word_cloze_contexts 是否随笔记级删除连带软删:RB remove_reading_page页级删除时按 source_url best-effort 清理。笔记级是否比照办理由 RB 决定 —— RVH 无本地采集路径(红线 #5g pull-only), 且 RVH 侧 source_url 匹配不可靠,故 RVH 不发起这一步。
  • clearLearningDataForUser 的 watermark 清理写错了 store —— v58 已修auth_repository_impl.dart:238 清的是 SharedPreferences 的 last_sync_at(全仓无任何写入方 → 纯 no-op),而 watermark 实际存于 app_metadata 表(sync_repository_impl.dart::_setLastSyncAt)。 修法不是「改成清对地方」,而是把 watermark 改成 per-user keylast_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):42await _detectUserSwitch 之前,新用户身份先广播 → autoSync 的首次 sync 与清库事务并发, 「清全局单值」方案的正确性取决于赢得这场竞争。回归测试见 test/features/sync/watermark_per_user_test.dart
  • insertBookConflictAlgorithm.replacereading_note_datasource_impl.dart:19):父表上的 INSERT OR REPLACE,同 id 重复插入会触发 CASCADE 抹子表。当前只在新建(新 UUID)路径调用,暂不触发; 属红线 #6b 精神的潜在 footgun,待单独评估