主题
16 — word_cloze_contexts 纳入跨端同步(RB 侧已完成 / RVH 会话交接)
物理位置:本文件在 RB 仓库
~/reading-browser/docs/cross-end/16-rb-cloze-context-sync-handoff.md。 RVH 会话用绝对路径读:Read ~/reading-browser/docs/cross-end/16-rb-cloze-context-sync-handoff.md。 跨仓库引用一律~-锚定绝对路径(RB→~/reading-browser/...,RVH→~/reading_vocab_helper/...)。创建:2026-07-09 · RB 先行(CLAUDE.md §9)· 关联 backlog 条目「word_cloze_contexts 纳入跨端同步」
⚠️ 2026-08-17 部分取代:本文件把 RVH 定位为「pull-only 消费方」的框定已失效——
word_cloze_contexts现为双端共写(RVH 的 OCR 采集经跨端评审放开,附四条硬条件)。 见23-rb-ocr-cloze-context-decision.md。 本文件的 §1 表契约 / §2 合并算法仍然有效,只是「谁会产行」那一句不再准。
0. 一句话目标
word_cloze_contexts(用户查词时正在读的真实句 + surface + sense_gloss,复习卡正面据此挖空生词)此前是 local-only(RB migration v16-v21)——用户在 RB 采集的语境,换到 RVH 复习完全看不到(RVH 复习卡只能 回落策展例句)。RB 侧已完成:本地 schema 加同步三列、全部硬删除改软删除、push/pull 双向同步 + 独有的 "合并后重新封顶"逻辑、Supabase 新建 user_word_cloze_contexts 表。
RVH 侧待做(本文件的交接目标):本地建这张表(RVH 目前没有)+ sync 适配(必须对齐 RB 的 merge 算法, 否则两端各按不同规则收敛会互相拉扯)+ 复习卡消费 cloze 语境(RVH 复习尚无此概念,这是"⑥ cloze v2" 主体价值所在,不只是同步管道)。
1. 表契约(握手核心 —— RVH 本地 schema 必须对齐)
1.1 本地 SQLite(RB word_cloze_contexts,migration v16→v17→v21→v24 累积形态)
sql
CREATE TABLE word_cloze_contexts (
id TEXT PRIMARY KEY,
word TEXT NOT NULL COLLATE NOCASE, -- 归一 lemma,join learning_entries.word
surface TEXT NOT NULL, -- 原始点击形,挖空精确替换用
sentence TEXT NOT NULL, -- 真实语境句(8..220 字符护栏)
source_url TEXT,
user_id TEXT,
created_at TEXT NOT NULL,
sense_gloss TEXT, -- v17:消歧出的贴合语境义项 gloss 文本(非 index)
updated_at TEXT, -- v24:同步脏检查
deleted_at TEXT, -- v24:软删墓碑
synced_at TEXT -- v24:同步水位
);
CREATE INDEX idx_cloze_word ON word_cloze_contexts(user_id, word COLLATE NOCASE, created_at DESC);无 UNIQUE 约束(v21 去除)——一词多语境池,应用层维持"去重 + 封顶 5"语义。无 FK(v16 起有意 松耦合,孤儿行无害)。
1.2 Supabase(user_word_cloze_contexts,全新建表)
sql
CREATE TABLE IF NOT EXISTS user_word_cloze_contexts (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
word TEXT NOT NULL,
surface TEXT NOT NULL,
sentence TEXT NOT NULL,
source_url TEXT,
sense_gloss TEXT,
created_at TEXT NOT NULL,
updated_at TEXT,
deleted_at TEXT,
server_updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
UNIQUE(user_id, word, sentence)
);远端有本地没有的 UNIQUE(user_id, word, sentence)——这是 push 的 on_conflict 目标,不是本地约束 (本地去重靠应用层查询)。RLS:auth.uid() = user_id(FOR ALL)。触发器 set_server_updated_at() 挂 BEFORE INSERT/UPDATE(与其余 9 张同步表同款)。完整 DDL 见 ~/reading-browser/supabase/sql/sync-tables.sql "5. user_word_cloze_contexts" 小节。
2. 合并算法(RVH 若要双向同步,必须复刻这一套,否则两端拉扯)
为什么难:其余 9 张同步表要么内容不可变(word_page_links)、要么走"更优覆盖"简单 merge (learning_entries 取 higher repetitions)。cloze 是"一词多语境池,应用层封顶 5 条"——两端各自封顶 5 条 的池子,pull 合并后可能变成 10 条,必须有一步"合并后重新收敛"。
2.1 采集时的去重 + 封顶(本地写路径,insert_cloze_context,src-tauri/src/commands/vocabulary/crud.rs)
- 命中同
(user_id, word, sentence COLLATE NOCASE)的已有行:- 活跃(
deleted_at IS NULL)→ no-op(幂等)。 - 已软删 → 复活(
UPDATE SET deleted_at=NULL, updated_at=now),不再插一条新行(避免"新行+旧墓碑")。
- 活跃(
- 无命中 → 若该 word 活跃行数已达
CLOZE_POOL_CAP(5) → 软删最旧一条(created_at ASC, id ASC排序, correlated subqueryLIMIT count-CAP+1)→ 再插入新行。
2.2 撤销 / 级联删除(全部软删)
remove_cloze_context(弹窗"撤销"):单行软删。remove_cloze_contexts_for_sentence(笔记句子级删除级联):sentence精确匹配 ORinstr子串兜底, 两路命中均软删。clear_cloze_context_for(删词级联,delete_vocabulary/batch_delete_vocabulary调用):按 word 软删。remove_reading_page(来源页级删除,notes.rs):按source_url软删。
全部改软删除的原因:硬删除会被下一轮 pull 当作"remote 未删的活行"复活——这是同步表的通用约束 (红线 #6),不是 cloze 特有。
2.3 push(push_cloze_contexts,src-tauri/src/commands/sync/push.rs)
脏检查:synced_at IS NULL OR updated_at > synced_at OR (deleted_at 新增)。on_conflict=user_id,word,sentence
Prefer: resolution=merge-duplicates(等价 PostgresON CONFLICT (user_id,word,sentence) DO UPDATE)。mark_synced仍按本地id标记——与 learning_entries/known_words/favorite_sites 等业务键 upsert 表同一套 既有行为:若两台设备各自为同一(word,sentence)生成了不同id的行(罕见竞态:都在离线时收集了同一句), 远端会以后写入者的id为准,先写入者下次 pull 时会走"本地无此 id → 插入"分支——这是全部业务键 upsert 表 共有的既有行为,本次未额外处理(与其它 9 张表一致的取舍,非 cloze 独有问题)。
2.4 pull(pull_cloze_contexts,src-tauri/src/commands/sync/pull.rs)
三分支墓碑传播(模板 = pull_word_sources):
- 本地存在 + remote 已删 → 标记本地墓碑
- 本地已删 + remote 活 → skip(本地墓碑走 push 传播,不被复活)
- 本地不存在 + remote 已删 → skip,不落墓碑
- 本地不存在 + remote 活 → 直接
INSERT(不做 FK 存在性检查——沿 v16 松耦合设计) - 本地存在 + remote 活 → sentence/surface 不可变;
sense_gloss取非空一方回填(双非空不覆盖)
合并后重新封顶(reconcile_cloze_pool,本表独有,其余 9 张表都不需要): 对本批次 pull 涉及的每个 distinct word:
- 拉出该词全部活跃行(
deleted_at IS NULL,ORDER BY created_at ASC, id ASC)。 - 按句去重:
sentence.to_ascii_lowercase()分组(与 SQLite 内置COLLATE NOCASE语义一致——都是 ASCII-only 折叠)。同组保留created_at最早一条为幸存行,sense_gloss从同组其它行回填(若幸存行为 空),其余同组行软删。 - 重新封顶:去重后若活跃行数仍 > 5,软删最旧的超额行(同 §2.1 的 correlated subquery 写法)。
这一步产生的软删行不在本轮闭环——它们会在下一轮 sync_now(60s 轮询)的 push 阶段作为墓碑传播出去, 让另一台设备也收敛到同一份 ≤5 条的池子。两轮内最终一致,不要求单轮内完成。
3. RVH 会话任务清单
全部在
~/reading_vocab_helper/。必须新会话(CLAUDE.md §9,Flutter/Dart 工具链)。
3.1 本地建表
- 建
word_cloze_contexts本地表,schema 照抄 §1.1(Dart/sqflite 语法转换)。RVH 目前没有这张表, 这是全新建表不是 ALTER。 word列需与 RVH 现有learning_entries.word同一归一口径(红线 #9 lemmatizer normalize)。
3.2 sync 适配
- Dart 端
sync_repository_impl.dart(或等价 sync 模块)新增 push/pull 分支,完整复刻 §2.3/§2.4 的 算法——尤其是 pull 后的reconcile_cloze_pool步骤,这是防止两端互相拉扯的关键,不能省略或简化。 - 撤销/删除路径全部走软删除(若 RVH 复习 UI 提供撤销/删除 cloze 语境的入口)。
3.3 复习卡消费("⑥ cloze v2"主体工作量,非纯同步管道)
- RVH 复习卡目前完全没有 cloze 概念(回落策展例句
vocabulary.pos_definitions挖空)。需要:- 复习卡正面按
word_cloze_contexts的语境句挖空(若该词有 cloze 行)。 - 多语境轮换(RB 侧按
repetitions % 语境数轮换,见docs/plans/archive/multi-context-cloze-plan.md)—— RVH 是否对齐同一轮换语义,或简化为"取最新一条",由 RVH 会话按 Dart 端复习卡架构决定。 sense_gloss若非空,复习背面按 gloss 文本重定位高亮(RB 侧record_cloze_sense/get_cloze_sense的等价逻辑)。
- 复习卡正面按
4. RB 侧已完成清单(本次会话,供 RVH 会话核对现状)
- [x] migration v24:
word_cloze_contextsALTER ADDupdated_at/deleted_at/synced_at - [x]
insert_cloze_context/record_cloze_sense/get_cloze_sense/remove_cloze_context/remove_cloze_contexts_for_sentence/clear_cloze_context_for/remove_reading_page(notes.rs)/get_due_cardscloze 批量读(srs.rs)/persist_sense_to_cloze(supabase.rs)全部适配软删除 +deleted_at IS NULL过滤 - [x]
clear_user_learning_data(signout 清理)补齐word_cloze_contexts/word_personalized_examples/phrase_interaction_log三张遗漏表(隐私缺口顺手修) - [x]
push_cloze_contexts+pull_cloze_contexts+reconcile_cloze_pool(sync/push.rs/sync/pull.rs) - [x]
sync/mod.rs编排:push/pull 顺序插入 +get_sync_statuspending_push 计数加第 10 项 - [x] Supabase DDL:
supabase/sql/sync-tables.sql新增 "5. user_word_cloze_contexts" - [x]
scripts/reset-dev-data.shTABLES 数组加user_word_cloze_contexts - [x]
CLAUDE.md/docs/database-schema.md/docs/database-tables-overview.md/docs/plans/cross-end-sync-evolution-roadmap.md同步矩阵计数从 9 张更新为 10 张 - [x]
cargo check通过
未改(有意):scripts/cross-end-check.sh 的 SHARED_SYNC 数组本次不加word_cloze_contexts——该数组是 RB↔RVH 双端都要 push 的"共享"表集合,RVH 完成 §3 之前加进去会让 cross-end-check 对 RVH 侧误报缺表。等 RVH 侧镜像完成后再加入。
5. 部署前置(Supabase)
user_word_cloze_contexts 是全新建表(非 ALTER 现有表)——必须先在 Supabase Dashboard 执行 supabase/sql/sync-tables.sql 里 "5. user_word_cloze_contexts" 那段 CREATE TABLE + 索引 + 触发器 + RLS policy,再让带 v24 代码的新版客户端上线(新客户端 push/pull 到该 endpoint 前表必须已存在)。
6. 验证清单
- [x] RB
cargo check通过 - [ ] RB
pnpm build - [ ] 删本地 dev DB 重跑
pnpm tauri dev,migration replay 到 v24 无报错 - [ ] Supabase Dashboard 执行 DDL 后,登录 + 触发 sync,
rb_supabase_query确认user_word_cloze_contexts有行落地,rb_db_query确认本地synced_at回写 - [ ] 双设备(或模拟)各攒 5 条不同语境后互相 pull,确认
reconcile_cloze_pool收敛回 ≤5 条而非 10 条 - [ ] RVH 完成 §3 后:跨端复习同一词能看到 RB 采集的真实语境句