Skip to content

数据库设计

权威来源:src-tauri/assets/sql/schema.sql(v1 冻结 baseline,2026-07-26)+ src-tauri/src/db/migrations.rs(当前链 v1+v2+v3,新迁移 v28+;见 §10) Supabase 同步表:supabase/sql/sync-tables.sql(10 张用户表 + 4 张推荐/公共表) 表速览(分组 + 数据流 + 关系图 + 同步/soft-delete 一览):docs/database-tables-overview.md(概念地图,与本文档互补:本文档管列级字典,overview 管概念/流向)


目录

  1. 概览
  2. 表关系图
  3. 学习核心表
  4. 阅读笔记表
  5. 词汇关联表
  6. 基础设施表
  7. 索引策略
  8. Soft-Delete 与同步
  9. 本地表 ↔ Supabase 表映射
  10. 迁移版本历史

1. 概览

25 张表(22 张已折叠进 v1 冻结 baseline + 冻结后 append-only 新增的 usage_events_local(v28) / word_encounters(v31) / epub_positions(v32);phrase_interaction_log 曾走迁移 v20,已于 v25 DROP 退役),按用途分 5 组:

分组表数表名
学习核心2vocabulary, learning_entries
阅读笔记5reading_notes, reading_pages, domain_prefs, page_annotations, epub_positions
词汇关联4word_page_links, word_cloze_contexts, word_personalized_examples, word_encounters
词汇过滤2known_words, default_stopwords
内容发现 & 统计11settings, recommended_sites, recommended_feeds, recommended_articles, rss_feeds, rss_items, favorite_sites, resource_history, reading_log, pronunciation_attempts, usage_events_local

同步表(10 张,有 synced_at 列):learning_entries, reading_notes, reading_pages, word_page_links, word_cloze_contexts, known_words, favorite_sites, rss_feeds, domain_prefs, page_annotations

注 1:reference_words 自 2026-04-21 起从同步表移除,定位为纯系统共享表(仅本地,所有用户共用 ~193 条预装专有名词)。 2026-08-31 再降一级:它不再参与任何查询过滤,因此已不算「词汇过滤」表 —— 现在只是一份带 chinese_meaning 的专名数据(留给 L-D 的中文兜底)。运行时的专名判据在 src-tauri/src/db/vocab_scope.rs

注 2:favorite_sites / rss_feeds / domain_prefs 自 2026-04-21 起加入同步矩阵——三张表带 user_id + 同步列;rss_items 同步(内容可再生,每端独立拉取,feed 删除时孤儿清理)。

注 3:page_annotations 自 2026-04-22(阶段 D)起加入同步矩阵——带 user_id + 同步列,跨端保留用户在某篇 reading_source 上的翻译/长难句分析结果。

注 4:word_cloze_contexts 自 2026-07-09(migration v24)起加入同步矩阵——此前 local-only(v16-v21)。一词多语境池的 merge 需要 pull 后额外做"按句去重 + 重新封顶 5"(reconcile_cloze_pool),是其余同步表都不需要的一步,详见 §8。

Soft-delete 表(10 张,有 deleted_at 列):learning_entries, known_words, reference_words, favorite_sites, rss_feeds, domain_prefs, page_annotations, reading_pages + word_page_links(v23 新增,笔记四级删除闭环), word_cloze_contexts(v24 新增)


2. 表关系图

全表关系图 / 数据流全景 / 按用途分组的速览统一见 docs/database-tables-overview.md §6-§7(唯一权威源)。 本文档聚焦列级定义,不再重复维护关系图,避免两份图漂移。


3. 学习核心表

vocabulary — 词库

预装 18,898 条词汇(含 idiom / phrasal_verb 短语;预装库 v18,2026-06-08;实测 SELECT COUNT(*) FROM vocabulary)。来源:src-tauri/assets/lampio_dict.db(与 RVH assets/databases/lampio_dict.db byte-equal 共享,红线 #10;2026-05-14 v10 起切换)。支持用户查词自动扩展(缓冲池 = Supabase vocabulary,预装库外的新词靠 backfill_word_inner 兜底补入)。

类型约束说明
wordTEXTPK COLLATE NOCASE单词归一形(lemmatize + NFC,写入前必经 lemmatizer::normalize
ipa_pronunciationTEXTIPA 音标
pronunciation_urlTEXT发音 URL
primary_cefr_levelTEXTA1-C2
pos_definitionsTEXTJSON:词性 + 中英释义(每 def 含 gloss/examples/synonyms/antonyms;RVH v19 起带 differentiation[{synonym,distinction_en,distinction_zh}] + collocations[{pattern,note_zh}],roadmap ⑤ 近义辨析/搭配)
frequency_rankINTEGER词频排名
sourceTEXTDEFAULT 'preinstalled'preinstalled / user_added
last_accessed_atTEXT最后查询时间
etymologyTEXT词源
word_formsTEXT词形变化
audio_local_pathTEXT本地音频路径
word_familyTEXT词族
cefr_inferredINTEGERNOT NULL DEFAULT 0CEFR 是否为推断值(RVH v48;SQLite INT 0/1 ↔ Supabase BOOLEAN)
cefr_sourceTEXTCEFR 来源:'oxford'/'cefr_j'/'llm'/'freq'/'fallback'/NULL(RVH v49)
word_tagsTEXTJSON 数组字符串,如 '["proper_noun"]'(RVH v50;正交于 CEFR 难度;短语库后含 idiom/phrasal_verb
emojiTEXTOpenMoji hexcode 大写(如 '1F436'),NULL=无图(migration v14 / RVH v53;不进 Supabase 同步矩阵)
canonical_surfaceTEXT习语可读引用形(如 'raining cats and dogs'),归一键 word('rain cat and dog')的展示形;NULL=归一无损回退 word(migration v15 / RVH v54)。纯展示,匹配/save 仍只用 word;不进 Supabase 同步矩阵
created_atTEXTNOT NULL
updated_atTEXTNOT NULL

learning_entries — 学习状态(SM-2)

每个用户学过的词一条记录,承载 SM-2 算法状态。

类型约束说明
idTEXTPKUUID
wordTEXTFK → vocabulary(word) ON DELETE RESTRICT NOT NULL COLLATE NOCASE与 vocabulary.word 直接关联(v9 跟 RVH v51 翻 CASCADE→RESTRICT;rusqlite 路径 FK 默认 OFF,但 plugin/sqlx 路径默认 ON;当前无 DELETE FROM vocabulary 业务路径,差异未触发,翻转价值是预防性)
user_idTEXTSupabase auth user 域
source_platformTEXTDEFAULT 'rb''rb' / 'rvh'
mastery_levelTEXTDEFAULT 'level0'level0-level5
easy_factorREALDEFAULT 2.5SM-2 难度因子
intervalINTEGERDEFAULT 0复习间隔(天)
repetitionsINTEGERDEFAULT 0复习次数
correct_countINTEGERDEFAULT 0正确次数
incorrect_countINTEGERDEFAULT 0错误次数
added_atTEXTNOT NULL加入时间
first_reviewed_atTEXT首次复习时间
last_review_dateTEXT最后复习日期
next_review_dateTEXT下次复习日期
last_review_qualityINTEGER上次复习质量(1-5,用于 2-button 序列推导)
average_response_timeREAL平均响应耗时
created_atTEXTNOT NULL
updated_atTEXTNOT NULL
synced_atTEXT最后同步时间
deleted_atTEXTSoft-delete 标记

约束:UNIQUE(user_id, word COLLATE NOCASE)(避免双端独立 INSERT 同 word,与 Supabase v43 一致)


4. 阅读笔记表

reading_notes — 笔记容器

一个笔记 = 一组阅读来源(如一篇网页文章、一本 EPUB)。

类型约束说明
idTEXTPKUUID
titleTEXTNOT NULL笔记标题
authorTEXT作者
note_typeTEXTDEFAULT 'webArticle'webArticle / epub
cover_image_dataBLOB封面图字节(压缩后 JPEG,192×192 q85,硬上限 100KB)
cover_image_mimeTEXT'image/jpeg'
cover_source_urlTEXT原始来源 URL(debug / 重抓)
descriptionTEXT描述
reading_progressREALDEFAULT 0.0阅读进度 0-1
source_platformTEXTDEFAULT 'rb'rb / rvh
created_atTEXTNOT NULL
updated_atTEXTNOT NULL
synced_atTEXT

reading_pages — 阅读来源

笔记下的具体内容来源。

类型约束说明
idTEXTPKUUID
note_idTEXTFK → reading_notes(id) NOT NULL
nameTEXTNOT NULL来源名称
source_typeTEXTDEFAULT 'web'web / book(EPUB) / text(粘贴文本)。无 CHECK 约束 —— 新增取值不需要迁移,text 即据此免费落地
image_pathTEXT图片路径(OCR 场景)
source_refTEXTURL 或文件路径
ocr_textTEXTOCR 识别文本
word_countINTEGER字数
created_atTEXTNOT NULL
updated_atTEXT
synced_atTEXT
content_htmlTEXT早期保留字段
cached_file_pathTEXT本地 library 相对路径(e.g. web/{hash}.html
user_idTEXT
storage_pathTEXTSupabase Storage reading-snapshots 桶内路径({user_id}/{rel}
last_opened_atTEXTSprint C-1 跨端续读(v12 加)。log_resource_open 在导航事件 touch(仅已存在行);sync 双向,pull merge 取 MAX;HomePage Continue Reading section 数据源;RVH 端不写此列
deleted_atTEXTSoft-delete 标记(v23 加,笔记四级删除闭环)。remove_reading_page 事务软删来源 + 级联软删其 word_page_links/page_annotations;ensure_source_for_url 重新 engage 时复活(清 deleted_at);log_resource_opendeleted_at IS NULL(仅导航不复活);sync push/pull 墓碑三分支传播

索引:idx_pages_last_opened (user_id, last_opened_at DESC) WHERE last_opened_at IS NOT NULL

domain_prefs — 域名偏好(同步表)

类型约束说明
user_idTEXTNOT NULL DEFAULT ''用户 ID('' 为未认证 sentinel,登录时清理)
domainTEXTNOT NULL域名
read_modeTEXTDEFAULT 'original'阅读净度档位:original / calm / reader(calm 2026-07-12 WP2 新增;应用层枚举、无 CHECK 约束,便于 additive 演进。前向兼容三纪律——读时白名单降级 / 存储原样透传 / 写回克制,见 docs/plans/archive/reading-cleanliness-tiers-plan.md §4)
note_idTEXTFK → reading_notes(id) ON DELETE SET NULL关联笔记
updated_atTEXT
synced_atTEXT
deleted_atTEXTSoft-delete 标记
PRIMARY KEY (user_id, domain)复合主键

domain_zoom — 域名整页缩放(本地表,不同步)

按域名持久化整页几何缩放倍率(webview pageZoom)。独立于 domain_prefs——避免污染其「行存在 = 用户显式表过 read_mode 态」的 nudge 语义(migrations.rs v26)。local-only:不进 Supabase 同步矩阵(RVH 无浏览/缩放概念),但含用户浏览域名 → 纳入 auth.rs 换用户/登出清理(红线 5c)。

类型约束说明
user_idTEXTNOT NULL DEFAULT ''用户 ID('' 为未认证 sentinel,登录时清理)
domainTEXTNOT NULL域名
zoomREALNOT NULL DEFAULT 1.0整页缩放倍率(1.0 = 100%)
updated_atTEXT
PRIMARY KEY (user_id, domain)复合主键

5. 词汇关联表

记录一个词在哪个来源中被学习。

类型约束说明
idTEXTPKUUID
learning_entry_idTEXTFK → learning_entries(id) NOT NULL
reading_page_idTEXTFK → reading_pages(id) NOT NULL
lemmaTEXTDEFAULT ''该语境中词的归一形/词目(非形态学词根,详见 vocabulary-domain-knowledge.md §2)
created_atTEXTNOT NULL
updated_atTEXT
synced_atTEXT
deleted_atTEXTSoft-delete 标记(v23 加,笔记四级删除闭环)。remove_word_page_link 软删单条;remove_reading_page 级联软删;save_word/batch_save_words/add_word_page_link 的写入用 ON CONFLICT DO UPDATE SET deleted_at=NULL 复活(红线 #7);所有消费查询加 ws.deleted_at IS NULL;sync 墓碑三分支传播
UNIQUE(learning_entry_id, reading_page_id)

6. 基础设施表

settings — 键值配置

类型说明
keyTEXT PK配置键
valueTEXT NOT NULL配置值

存储:Supabase URL/anon_key、auth_user_id、last_sync_at / last_sync_attempt_at、vocabulary_seed_version 等。

预装英文站点(BBC/NPR/Aeon 等),App 启动时从 Supabase recommended_sites 远程刷新覆盖 is_default=1 的记录。首页 Recommended Sites 区数据源。

类型约束说明
idINTEGERPK
nameTEXTNOT NULL站点名
urlTEXTNOT NULL站点 URL
categoryTEXTNOT NULL分类
icon_urlTEXT图标 URL
sort_orderINTEGERDEFAULT 0排序
is_defaultINTEGERDEFAULT 1是否预装默认(远程刷新只覆盖 =1 的行)
has_rssINTEGERDEFAULT 0是否有 RSS 源
rss_urlTEXTRSS 源 URL
cefr_bandTEXTv19 加列,站点难度档(A2/B1/B2/C1,discover-sites grounded 审核算出 + ?mode=backfill-bands 回填,NULL=未知);首页 tier-aware 站点分桶排序用

rss_feeds / rss_items — RSS 订阅(feeds 同步,items 不同步)

  • rss_feedsid TEXT PRIMARY KEY (UUID) + user_id + 同步列;UNIQUE (user_id, url)
  • rss_itemsfeed_id TEXT 引用 rss_feeds.id;不参与同步(每端独立拉取,feed 删除/切换用户时按 feed_id NOT IN (SELECT id FROM rss_feeds) 孤儿清理)。
rss_feeds 列类型约束说明
idTEXTPKUUID
user_idTEXT用户 ID(NULL = 未认证 sentinel)
titleTEXT
urlTEXTNOT NULLUNIQUE(user_id, url)
site_urlTEXT
tagTEXTv8 加列,从 recommended_feeds.tag 透传;手动订阅时为 NULL
last_fetchedTEXT
created_atTEXTNOT NULL
updated_atTEXT
synced_atTEXT
deleted_atTEXT

favorite_sites — 网站收藏(domain 级别,同步表)

类型约束说明
idTEXTPKUUID
user_idTEXT用户 ID(NULL = 未认证 sentinel)
domainTEXTNOT NULL域名(如 bbc.com);UNIQUE(user_id, domain COLLATE NOCASE)
nameTEXT站点名称
urlTEXTNOT NULL站点首页 URL
icon_urlTEXT站点图标 URL
site_typeTEXTDEFAULT 'user'user / recommended
categoryTEXTv8 加列,从 recommended_sites/RecommendedSite.category 透传;手动收藏时为 NULL
created_atTEXTNOT NULL
updated_atTEXT
synced_atTEXT
deleted_atTEXTSoft-delete 标记

resource_history — 资源访问历史(本地,不同步)

统一记录 web/epub 打开记录(去重),首页 Recently Opened + 历史面板数据源。per-user 隔离(v4)。

🔒 粘贴文本刻意记作 'web'(不是 'text')。resource_type 这一列 CHECK 约束, 而 SQLite 不支持 ALTER TABLE DROP CONSTRAINT,放开它要整表重建 —— 为历史面板的类型筛选 做一次表重建不划算(本仓最需谨慎的操作类型)。代价仅是历史面板里粘贴文本与网页混在一起。 ⚠️ 后续会话若"顺手"把它改成 'text',会当场 CHECK 违约。见 paste-text-source-plan.md §5.6。

类型约束说明
idINTEGERPK
user_idTEXTper-user 隔离
resource_typeTEXTCHECK IN ('web','epub')粘贴文本记 'web'(见上方说明)
uriTEXTURL 或文件路径;UNIQUE(user_id, uri)
titleTEXT
last_opened_atTEXTNOT NULL
open_countINTEGERDEFAULT 1

reading_log — 阅读会话统计(本地,不同步)

每次阅读会话写一行明细(save_reading_log 周期性写入)。StatsPanel 的 trends/weekly/monthly/streaks 数据源(v13 补建)。与 resource_history 互补:后者"访问过哪些"(去重),前者"每次读了多少/多久/几个生词"(可 SUM/GROUP BY)。

类型约束说明
idINTEGERPK AUTOINCREMENT
urlTEXTNOT NULL
titleTEXT
domainTEXT
word_countINTEGERNOT NULL DEFAULT 0本次阅读字数
new_wordsINTEGERNOT NULL DEFAULT 0出现生词数
cefr_levelTEXT
read_timeINTEGERNOT NULL DEFAULT 0阅读时长(秒)
created_atTEXTNOT NULL
user_idTEXTv27 加:阅读统计 per-user。所有 stats 查询按 current_user_id 过滤,消除换用户后旧用户阅读趋势泄漏。不进换用户清理循环(按 user_id 留存、查询隔离 = 零数据丢失)。仍不同步(设备本地)。存量行迁移时回填当前 auth_user_id

App 启动时从 Supabase recommended_feeds 刷新。首页 Recommended Feeds 区数据源。

类型约束说明
idINTEGERPK
titleTEXTNOT NULL
urlTEXTNOT NULL UNIQUE
tagTEXTNOT NULL分类标签
descriptionTEXT
sort_orderINTEGERDEFAULT 0
is_defaultINTEGERDEFAULT 1
quality_scoreINTEGERDEFAULT 3
is_activeINTEGERDEFAULT 1
fetched_atTEXT
cefr_bandTEXTv19 加列,继承父站 band(同 rss_url,NULL=未知);首页 tier-aware feed 分桶排序用

default_stopwords — 系统默认停用词(本地预装,不拉取)

系统停用词池(a/the/is…)。2026-07 决策5 起纯本地预装:由 init_data.sql 安装期播种 211 行(id 沿用确定性 sys-ew-*,RB/RVH 跨端一致),不再从 Supabase 拉取(Supabase 公开表已 DROP,fetch_default_stopwords 拉取路径已删)。仅 3 列;职责单一——作为 merge 源把词 INSERT OR IGNORE 进用户的 known_words(apply_default_stopwords 在 init/登录时触发)。

类型约束说明
idTEXTPK
wordTEXTNOT NULL COLLATE NOCASEUNIQUE(word COLLATE NOCASE)
sort_orderINTEGERDEFAULT 0

pronunciation_attempts — 跟读评分历史(本地,不同步)

Azure Speech 发音评分明细,per-user。v11 新建,local-only MVP,同步矩阵留后续。

word_cloze_contexts — 复习卡 cloze 真实语境(同步,v24 起)

roadmap ⑥。存用户读到某词时的真实句 + 原始点击形 surface,复习卡正面据此把生词挖空(回忆卡)。 列:id / word(归一 lemma,join 复习卡)/ surface(精确挖空目标)/ sentence / source_url / user_id / created_at / sense_gloss(v17,roadmap ⑥+:消歧出的贴合该句语境的义项 gloss 文本,复习背面按文本重定位高亮;存 gloss 非 index 规避 reseed 漂移;NULL=未消歧/非多义;由 record_cloze_sense 按 (word, sentence) 句级回写) / updated_at / deleted_at / synced_at(v24,同步三列)。 v21 多语境池(docs/plans/archive/multi-context-cloze-plan.md):去掉原 UNIQUE(user_id, word),一词多行; 去重(同句 COLLATE NOCASE no-op,或复活同句软删行)+ 封顶 5 条(满池软删最旧)由应用层 insert_cloze_context 保证。 收集路径 = 查词即自动收录:save_word(首存)与 add_word_page_link(已存词再查,弹窗「已收录本句语境 · 撤销」→ remove_cloze_context)。 复习卡按 repetitions % 语境数 轮换测试句,揭晓后可在语境间切换(‹n/m› + 顶栏 stepper)。 v16 新建 / v21 去 UNIQUE 重建 / v24 纳入跨端同步矩阵(ALTER ADD updated_at/deleted_at/synced_at, 撤销/满池替换/来源级删除全部从硬删除改软删除);含用户阅读句 → clear_learning_data_if_user_changed

  • clear_user_learning_data(signout)均已纳入(红线 5c)。 merge 是本表独有难点:两端各自封顶 5 条的池子 pull 合并后可能变 10 条,pull_cloze_contexts 三分支 墓碑传播后跑 reconcile_cloze_pool(按句去重 + 重新封顶 5),详见 §8 及 docs/cross-end/16-rb-cloze-context-sync-handoff.md。 无真实句时复习卡前端回落策展例句(vocabulary.pos_definitions)挖空,故此表稀疏、仅承载"用户自己读过的句"。

word_personalized_examples — 个性化例句缓存(本地,不同步)

roadmap ④。复习卡揭晓侧"再看一个新语境例句"按需生成的结果缓存。锚定 sense_gloss(贴合语境义项)+ 用户真实读句 + 兴趣信号(get_reading_interest_signals 的常读 domain / 订阅 tag),经 Edge Function generate-example 生成贴近用户兴趣的新英文例句 + 中文翻译。 列:id / user_id / word(归一 lemma,join 复习卡)/ example_text / example_zh / sense_gloss(生成时锚定义项,仅来源标注)/ created_at无 UNIQUE:一词多条("换一个"累积),generate_personalized_example 命令插入后按 (user_id, word) prune 仅留最新 5 条。v18 新建,local-only(migration-only 不动 baseline)。不进同步:个性化锚定单用户、与缓冲池 跨用户摊薄互斥(取个性化);跨端可见性挂 ⑥ cloze v2(RVH 新会话)。含用户兴趣派生内容 → clear_learning_data_if_user_changed 已纳入(红线 5c)。opt-in 复用 context_disambiguation,无独立开关。

epub_positions — EPUB 章内阅读位置(本地,不同步)

backlog B4 剩余部分的落点(migration v32)。「恢复到」2026-08-07 已用 reading_pages.source_ref 里现成的 fragment 零 migration 做完;章内偏移没有任何现成载体。 实测支撑:最长章可滚 65,977px ≈ 47 屏,读者确实会在一章里读很远,重开却只回章首。

类型约束说明
idINTEGERPK AUTOINCREMENT
user_idTEXTNOT NULL未登录时命令 no-op(不留 NULL 行)
file_pathTEXTNOT NULL剥掉 fragment 的纯 .epub 路径
source_refTEXTNOT NULL章:路径#锚点 / 路径#ch{N},与 reading_pages.source_ref 同形
block_idxINTEGERNOT NULL DEFAULT 0章内第 N 个文本块
block_fracREALNOT NULL DEFAULT 0该块内已滚过的比例 0..1
updated_atTEXTNOT NULL DEFAULT (now)
UNIQUE(user_id, file_path)一本书一行(UPSERT 原地更新)

存结构量不存像素settings.jsrb-cache:// 页的整页重排版实测把文档撑 +85% (B5 的真凶),像素当场失真;块序号是 DOM 结构量,换字号 / 栏宽 / 窗宽都不变。 也不存「最近可见的锚点 id」——2026-08-13 实测两本书的锚点密度正好相反: Gutenberg 每 618 字符一个锚点(很好),Standard Ebooks 每章只有一个(53,936 字符一个, 等于只能恢复到章);而 <p> 密度反过来(1,368 : 327),块序号在两类书上都够密。

为什么是新表而不是 reading_pages 加两列:那张表的行只在存词 / 划线时经 ensure_source_for_url 产生,而本表的场景恰恰是只读不存词。新表也保证不 touch last_opened_at → 「继续阅读」排序行为零变化。

block_idx 的含义只由 content-script 定义features/epub-anchor.js::collectPositionBlocks, 存取共用同一枚举口径)——Rust 侧只当仓库,别在第二处解释它。

per-user 走 user_id + 读查询过滤(沿 v27 reading_log / v31 先例:不进 auth.rs 换用户 清理循环,换用户靠过滤天然隔离)。不进同步:RVH 无 EPUB 阅读概念,且 file_path 是设备本地路径。 消费方 commands/epub/position.rs;落点决策在前端 lib/epubStart.ts::resolveEpubStart

word_encounters — 候选词跨页复现计数(本地,不同步)

roadmap 3-2「待发现词前台收敛」的排序信号表(migration v31)。DiscoveryPanel 潜在词从字母序 改为价值排序,四维信号里「跨文复现」在本仓没有任何数据源——word_page_linksJOIN learning_entries(只记已存词的来源页)、reading_log 只存聚合计数、无 per-page 词索引。

类型约束说明
idINTEGERPK AUTOINCREMENT
wordTEXTNOT NULL COLLATE NOCASE候选词(值来自 vocabulary.word,写前过 lemmatizer::normalize
user_idTEXT当前用户;未登录时命令 no-op 不写 NULL 行
page_countINTEGERNOT NULL DEFAULT 1读到过的次数(不是「篇数」,见下)
last_page_urlTEXT去重闸,非历史记录
last_seen_atTEXTNOT NULL DEFAULT (now)
UNIQUE(word, user_id)per-user 一词一行

设计要点 = 聚合表而非 (word,url) 明细表:行数被候选池大小封顶(≤ ~12k = 预装库落在启用 CEFR 档 的词数),不随阅读量线性增长;明细表在重度用户身上是 ~1M 行/年。

last_page_url 是去重闸highlightDiscoverable 会因 SPA 导航 / MutationObserver 在同一页多次 重跑,没有这个闸单页就被反复 +1(UPSERT 用 CASE WHEN last_page_url IS ?3 的 NULL-safe 相等)。 接受的近似A→B→A 回访会重复计数 → UI 文案只说「读到过 N 次」,绝不说「N 篇文章」

per-user 走 user_id + 读查询过滤(沿 v27 reading_log 先例:零数据丢失、不进 auth.rs 换用户清理循环,换用户靠过滤天然隔离)。不进同步:RVH 无浏览/页面发现概念。 消费方 commands/vocabulary/discovery.rs;打分排序在前端 lib/discoveryRank.ts

phrase_interaction_log(已退役,v25 DROP):v20 建的短语自动高亮 thin-slice 验证埋点。 全仓从无 SELECT(纯只写)、设计的 dismissed 事件从无触发点、gate 验证早已改走 [RB-EVAL] console + 逐句判定绕开本表;静态治理冻结后再无消费者 → v25 DROP TABLE。前向迁移退役(不改 已应用的 v20,规避 sqlx checksum 校验失败)。


7. 索引策略

高频查询索引

索引用途
vocabularyword COLLATE NOCASE查词(大小写不敏感)
vocabularyprimary_cefr_levelCEFR 过滤
vocabularyfrequency_rank词频排序
learning_entriesnext_review_date获取到期复习卡片
learning_entriesword按词汇文本查学习状态(v43 后 PK 直查)
reading_logcreated_at / (url, created_at)StatsPanel 趋势/streak 聚合
resource_history(user_id, last_opened_at)首页 Recently Opened

同步索引

所有同步表在 (user_id, updated_at) 上有组合索引(Supabase 端),用于增量拉取。

注意事项

  • 禁止 LOWER():SQLite 不支持 Unicode,所有大小写不敏感查询用 COLLATE NOCASE
  • 索引命名约定:idx_{表缩写}_{列名}

8. Soft-Delete 与同步

Soft-Delete(10 张表有 deleted_at)

learning_entries, known_words, favorite_sites, rss_feeds, domain_prefs, page_annotations, reading_pages, word_page_links(均同步;后两张 v23 新增,笔记四级删除闭环), word_cloze_contexts(v24 新增)+ reference_words(本地系统表,带 deleted_at 但不同步)。

删除 → UPDATE SET deleted_at = datetime('now')(不做 DELETE)
查询 → WHERE deleted_at IS NULL
保存 → 若已 soft-delete,重新激活(learning_entries 重置 SM-2 默认值)

同步 Push 条件

sql
-- 正常变更
WHERE updated_at > synced_at

-- Soft-delete 传播(fallback)
OR deleted_at > synced_at

同步 Pull Merge

对于 learning_entries,取 remote 若满足任一:
  - remote.repetitions > local.repetitions
  - remote.next_review_date > local.next_review_date
  - remote.mastery_level > local.mastery_level

对于 reading_notes/reading_pages、word_page_links:
  - 直接 upsert(remote 优先)
  - word_page_links 插入前检查 FK 存在性(notebook_entry / reading_source)
  - reading_pages / word_page_links soft-delete 墓碑三分支(v23,模板=learning_entries):
    remote 删+本地有 → 标墓碑;本地已删+remote 活 → skip(不复活);本地无+remote 删 → skip 不落墓碑(FK-safe)

对于 word_cloze_contexts(v24):
  - 三分支墓碑传播同上(不做 FK 存在性检查,沿 v16 松耦合设计);sense_gloss 取非空一方回填
  - **额外一步(本表独有)**:pull 结束后对本批次触达的每个 word 跑 reconcile_cloze_pool——
    按句 COLLATE NOCASE 去重 + 软删最旧超额行,把两端各封顶 5 条合并后的池子重新收敛到 ≤5 条

9. 本地表 ↔ Supabase 表映射

映射总览

本地表Supabase 表差异
learning_entriesuser_learning_entries三端已对齐(v43 PK 由 vocabulary_id 改 word + UNIQUE(user_id, word))
reading_notesuser_reading_notes三端已对齐(v34 加 user_id/full_text_path)
reading_pagesuser_reading_pages三端已对齐(v34 加 user_id)
word_page_linksuser_word_page_links三端已对齐(v34 加 user_id)
word_cloze_contextsuser_word_cloze_contexts2026-07-09(v24)新增,全新建表;on_conflict 业务键 (user_id, word, sentence),RVH 待镜像
known_wordsuser_known_words三端已对齐(v34 加 user_id)
favorite_sitesuser_favorite_sites2026-04-21 阶段 C 新增(id TEXT UUID + user_id + 同步列)
rss_feedsuser_rss_feeds2026-04-21 阶段 C 新增(id TEXT UUID + user_id + 同步列)
domain_prefsuser_domain_prefs2026-04-21 阶段 C 新增(复合 PK (user_id, domain) + 同步列)
page_annotationsuser_page_annotations2026-04-22 阶段 D 新增(PK id TEXT + user_id + 同步列;reading_page_id 逻辑 FK)

所有 Supabase 用户表启用 RLS,策略:auth.uid() = user_id

不同步的本地表(12 张):vocabulary(预装库 + Supabase 缓冲池回填)、settings、recommended_sites、recommended_feeds、recommended_articles(这三张为 ← Supabase 单向拉取/缓存)、default_stopwords(纯本地预装 seed,2026-07 决策5 起不拉取)、rss_items、resource_history、reading_log、pronunciation_attempts、word_personalized_examples(个性化例句缓存,v18;个性化锚定单用户、与缓冲池摊薄互斥)、reference_words(系统共享,~199 条预装专有名词)。word_cloze_contexts 自 2026-07-09(v24)起移出此列表,纳入同步矩阵(见上方映射表)。phrase_interaction_log 曾在此列表(v20),已于 v25 DROP 退役。

rss_items 不同步说明:RSS 文章内容可由 feed URL 重新拉取,每端独立维护更轻量;同时 feed_id FK 跟随 rss_feeds 同步生命周期,user 切换时通过 feed_id NOT IN (SELECT id FROM rss_feeds) 孤儿清理。

推荐系统表映射(公开表,无需登录)

Supabase 表本地缓存说明
recommended_sitesrecommended_sites站点推荐池,含 quality_score / is_active / has_rss
recommended_feedsrecommended_feedsRSS 源推荐池
recommended_articlesrecommended_articlesAI 推荐文章,7 天过期
default_stopwordsdefault_stopwords已下线拉取(2026-07 决策5):改纯本地预装 seed,Supabase 公开表已 DROP

推荐表启用 RLS,策略:SELECT USING (true)(公开只读)。

App 启动时后台 fetch 三张推荐表 → 缓存到本地 → 首页展示。网络失败时降级到本地数据。

类型说明
idINTEGER PK本地自增
remote_idINTEGERSupabase 端 id
titleTEXT NOT NULL文章标题
urlTEXT NOT NULL文章 URL
source_nameTEXT来源名
summaryTEXT摘要
cefr_levelTEXT DEFAULT 'B1'CEFR 等级
categoryTEXT分类
recommendation_reasonTEXT中文推荐理由
published_atTEXT发布时间
fetched_atTEXT NOT NULL拉取时间

推荐算法详见docs/recommendation-algorithm.md

执行脚本supabase/sql/sync-tables.sql(在 Supabase Dashboard → SQL Editor 中运行)

Supabase 表完整列定义

user_learning_entries

类型约束本地对应
idTEXTPKid
user_idUUIDNOT NULL FK → auth.users(id) CASCADEuser_id
wordTEXTNOT NULLword
mastery_levelTEXTNOT NULL DEFAULT 'level0'mastery_level
easy_factorREALNOT NULL DEFAULT 2.5easy_factor
intervalINTEGERNOT NULL DEFAULT 0interval
repetitionsINTEGERNOT NULL DEFAULT 0repetitions
correct_countINTEGERDEFAULT 0correct_count
incorrect_countINTEGERDEFAULT 0incorrect_count
added_atTEXTNOT NULLadded_at
first_reviewed_atTEXTfirst_reviewed_at
last_review_dateTEXTlast_review_date
next_review_dateTEXTnext_review_date
last_review_qualityINTEGERlast_review_quality
average_response_timeREALaverage_response_time
source_platformTEXTNOT NULL DEFAULT 'rb'source_platform
deleted_atTEXTdeleted_at
created_atTEXTNOT NULLcreated_at
updated_atTEXTNOT NULLupdated_at

约束:UNIQUE(user_id, word)(v43 加,与本地约束对齐)

索引:(user_id), (word), (user_id, next_review_date), (user_id, updated_at)

user_reading_notes

类型约束本地对应
idTEXTPKid
user_idUUIDNOT NULL FK → auth.users(id) CASCADE❌ 本地无
titleTEXTNOT NULLtitle
authorTEXTauthor
note_typeTEXTDEFAULT 'webArticle'note_type
cover_image_dataBYTEAcover_image_data(PostgREST \x hex transit)
cover_image_mimeTEXTcover_image_mime
cover_source_urlTEXTcover_source_url
descriptionTEXTdescription
reading_progressREALDEFAULT 0.0reading_progress
source_platformTEXTNOT NULL DEFAULT 'rb'source_platform
created_atTEXTNOT NULLcreated_at
updated_atTEXTNOT NULLupdated_at

索引:(user_id), (user_id, updated_at)

user_reading_pages

类型约束本地对应
idTEXTPKid
user_idUUIDNOT NULL FK → auth.users(id) CASCADE❌ 本地无
note_idTEXTNOT NULLnote_id
nameTEXTNOT NULLname
source_typeTEXTNOT NULL DEFAULT 'web'source_type
image_pathTEXTimage_path
source_refTEXTsource_ref
ocr_textTEXTocr_text
word_countINTEGERword_count
storage_pathTEXTstorage_path(bucket reading-snapshots 内路径 {user_id}/{rel}
last_opened_atTEXTlast_opened_at(Sprint C-1 跨端续读;RVH 端 pull-only)
created_atTEXTNOT NULLcreated_at
updated_atTEXTupdated_at
deleted_atTEXTdeleted_at(v23 笔记四级删除,Dashboard ALTER … ADD COLUMN IF NOT EXISTS deleted_at;RVH 待镜像)

索引:(user_id), (user_id, note_id), (user_id, updated_at), (user_id, last_opened_at DESC) WHERE last_opened_at IS NOT NULL

Storage:配套 Supabase Storage 私有 bucket reading-snapshots,RLS 按 split_part(name,'/',1) = auth.uid() 隔离(见 supabase/sql/storage-setup.sql)。

类型约束本地对应
idTEXTPKid
user_idUUIDNOT NULL FK → auth.users(id) CASCADE❌ 本地无
learning_entry_idTEXTNOT NULLlearning_entry_id
reading_page_idTEXTNOT NULLreading_page_id
lemmaTEXTNOT NULL DEFAULT ''lemma
created_atTEXTNOT NULLcreated_at
updated_atTEXTupdated_at
deleted_atTEXTdeleted_at(v23 笔记四级删除,Dashboard ALTER … ADD COLUMN IF NOT EXISTS deleted_at;RVH 待镜像)
UNIQUE(learning_entry_id, reading_page_id)

索引:(user_id), (user_id, updated_at)

Supabase 运营 / 配额表(不进同步矩阵、不进 RB 本地镜像

这几张表只存在于 Supabase,由 Edge Function 以 service_role 写入。 DDL 权威源:supabase/sql/observability-tables.sql + supabase/sql/quota-and-attribution.sql (Dashboard 手动执行)。设计缘由见 docs/plans/archive/llm-server-unification-plan.md

作用关键列RLS
llm_call_log每次 LLM 调用一行(成本核算)user_id(2026-08 加) / function_name / prompt_tokens / completion_tokens / cost_usd / status仅 service_role
tts_call_logTTS 账本。kind='synthesize' 精确计字符;kind='token_issue'发放日志不是用量日志(token 出门后客户端直连 Azure,服务端不可见)user_id / kind / char_count / cost_usd仅 status='ok' 时有值仅 service_role
user_quota_usage配额计数器。数的是用户动作,与 llm_call_log调用次数故意不同粒度PK (user_id, period, meter) / units / cost_usd(尽力而为,权威成本看两张 log 表)用户可 SELECT 自己那行;写仅 service_role
quota_config单行阈值表(id=1)。调阈值 = 一条 SQL UPDATE,不改代码不重部署llm_calls_limit / llm_calls_passive_limit / translate_paragraphs_limit / tts_chars_limit / speech_tokens_limit仅 service_role
edge_run_log / recommend_audit_log推荐链路 run 审计(Phase 1 既有)仅 service_role

meter 取值llm_calls(主动:拆解/消歧/例句/指代/周报)· llm_calls_passive(被动:短语自动高亮)· translate_paragraphs(按段)· tts_chars(按字符)· speech_tokens(按发放次数)。

主动/被动为何分开:实测 judge-phrases-batch被动触发的(用户只是在读页面, 短语高亮就在后台跑)且吃掉 78% 的 token。与主动动作共用一个 meter 会让用户 因为读得多而撞墙,违反「墙不能压到核心阅读闭环」。

匿名调用记入哨兵 user_id = '00000000-0000-0000-0000-000000000000'。 该行永远不等于任何 auth.uid(),RLS 天然不暴露;它同时是「RVH 是否已完成 JWT 迁移」 的观测指标(停止增长 = 可进 WP3 阶段 2 拒绝匿名)。

本地 vs Supabase 差异总结

差异点说明
user_idSupabase 每张表多 user_id UUID 列,用于 RLS 隔离
source_platformlearning_entries + reading_notes 有此列,标识数据来源(rb/rvh)
synced_at本地有、Supabase 无 — 仅用于本地判断"哪些行需要 push"
deleted_atlearning_entries 两端都有,用于 soft-delete 跨端传播
last_review_qualitylearning_entries 两端都有(通过 ALTER TABLE 增量添加)

10. 迁移版本历史

🔒 2026-07-26 数据基线冻结(基线 0.1.0-dev.8:旧 v1→v27 全链已折叠进 v1 baseline(schema.sql),自此 append-only——新表/列只进 v28+(详见 CLAUDE.md §5 + 红线 #11)。下表 v4-v27 均已折叠进 v1,仅作历史索引。

当前链 = v1(冻结 baseline)+ v2(seed init_data)+ v3(seed reference_words)+ v28…v34,下一个新迁移从 v35 开始 (v28 起是冻结后的 append-only 新增,均不动 schema.sql)。

📍 本节是迁移史的唯一真相源。 CLAUDE.md §5 只留冻结纪律与链头指针,不再并行维护第二张表 ——两边各写一遍必然漂移(2026-08 实测:两份表同时漏记 v33,且 CLAUDE.md 那份把早已完成的 v28 收尾项写作"阻塞中")。核对链头请以 src-tauri/src/db/migrations.rs 为准,它比任何文档新鲜。

版本内容
v1Schema snapshot(assets/sql/schema.sql2026-07-26 冻结)— 全部表 + 索引 baseline(含旧 v4-v27 全部 schema 效果)
v2Seed init data(settings、recommended_sites、known_words 停用词)
v3Seed reference_words(~199 条系统专有名词/缩写)
v4resource_history 加 user_id + UNIQUE(user_id, uri) 重建(阶段 A)
v5known_words 拆 per-user:system 停用词剥离到 default_stopwords(阶段 F)
v6page_annotations 加 user_id + 同步列(阶段 D)
v7reading_notes: cover_image_path → cover_image_data BLOB + cover_image_mime + cover_source_url
v8favorite_sites + rss_feeds: ALTER ADD COLUMN(favorite_sites.category / rss_feeds.tag),不动 v1 baseline 避免 hash 校验拒绝
v9vocabulary: +cefr_inferred/+cefr_source/+word_tags(RVH v48-v50);learning_entries word FK CASCADE→RESTRICT(RVH v51)
v10vocabulary 预装源从 vocabulary_seed.sql (4,953) 切到 lampio_dict.db (12,291,与 RVH byte-equal);migration 只清 vocabulary_seed_version,重灌由 helpers.rs ATTACH+INSERT 完成
v11pronunciation_attempts 表新建(per-user 跟读评分,local-only MVP)
v12reading_pages: ALTER ADD last_opened_at + 索引(Sprint C-1 跨端续读)
v13reading_log: 补建表(stats 数据源,6a37c51 压平时漏建)
v14vocabulary: ALTER ADD emoji(词汇插图 OpenMoji hexcode,RVH v53 additive)
v15vocabulary: ALTER ADD canonical_surface(习语可读引用形展示,RVH v54 additive)
v16word_cloze_contexts 表新建(复习卡 cloze 真实语境,local-only;save_word 加 context_sentence 入参)
v17word_cloze_contexts ALTER ADD sense_gloss(复习卡背面贴合语境义项高亮,存 gloss 文本;record_cloze_sense 回写)
v18word_personalized_examples 表新建(roadmap ④ 个性化例句缓存,local-only;generate_personalized_example 命令 + Edge Function generate-example)
v19recommended_sites + recommended_feeds: ALTER ADD cefr_band(tier-aware 推荐排序,additive)
v20phrase_interaction_log 表新建(短语自动高亮 thin-slice v1 验证埋点,local-only;log_phrase_interaction 命令 + phrase_auto_highlight 开关)
v21word_cloze_contexts 去 UNIQUE(user_id, word) 重建(一词多语境池:应用层去重 + 封顶 5;收集=查词即自动收录,复习=轮换+切换。详见 docs/plans/archive/multi-context-cloze-plan.md)
v22word_cloze_contexts prune 展示护栏外句长存量行(8..220 chars;配套入池闸三处对齐:Rust insert_cloze_context / popup.js checkContextIsNew / cloze.ts MIN·MAX_SENTENCE_LEN)
v23word_page_links + reading_pages: ALTER ADD deleted_at(笔记四级删除闭环)。两表同补软删列(子表对 reading_pages FK 均 CASCADE,硬删会先于墓碑 push 抹掉子表墓碑)。配套 remove_reading_page(事务软删+级联 cloze+返回 highlight ids)/ remove_word_page_link / remove_cloze_contexts_for_sentence;三处写入 ON CONFLICT DO UPDATE 复活(红线 #7);sync 双表墓碑三分支。Supabase 两表 Dashboard ALTER ADD deleted_at(须先于新版客户端)。详见 docs/plans/archive/notes-crud-phase2-plan.md
v24word_cloze_contexts: ALTER ADD updated_at/deleted_at/synced_at(纳入跨端同步矩阵,第 10 张同步表)。撤销/满池替换/来源级删除全部从硬删除改软删除;insert_cloze_context 命中同 (word,sentence) 软删行改为复活(红线 #7)。新增 push_cloze_contexts/pull_cloze_contexts(模板=word_page_links,不做 FK 检查)+ pull 后 reconcile_cloze_pool(按句去重+重新封顶 5,merge 两端各 5 条池子的核心难点)。Supabase 新建 user_word_cloze_contexts(全新表)。顺手补齐 clear_user_learning_data 遗漏的三张含用户内容表。详见 docs/cross-end/16-rb-cloze-context-sync-handoff.md
v25phrase_interaction_log: DROP TABLE — 短语高亮验证埋点退役(纯只写从无 SELECT、dismissed 从无触发点、gate 验证早已绕开本表、静态治理冻结后无消费者)。前向迁移退役(不改已应用的 v20,规避 sqlx checksum 校验失败)。local-only → 无跨端 / Supabase 协调。同步移除 log_phrase_interaction 命令 + content-script 两处埋点调用 + auth.rs 两处清理清单
v26domain_zoom 表新建(按域名持久化整页缩放,local-only;get_domain_zoom/set_domain_zoom)
v27reading_log: ALTER ADD user_id(阅读统计改 per-user)。设备级表泄漏根治——换账号后新账号继承旧用户阅读趋势(roadmap 2-4 实测暴露 + 隐私)。所有 reading_log 读查询(reading.rs 8 / report.rs 3 / supabase.rs 1)按 current_user_id 过滤;不进 auth.rs 换用户清理循环(按 user_id 留存、查询隔离 = 零数据丢失);存量行回填当前 auth_user_id。ALTER-ADD、不动 baseline schema.sql(红线 #11)。仍 local-only。配套 auth.rs 换用户清理补入 weekly_report_cache(全局 KV 隐私泄漏,沿 last_known_cefr_level 先例)
—— 以下为 2026-07-26 冻结后的 append-only 新增(均不动 schema.sql)——
v28usage_events_local 表新建(opt-in 匿名遥测本地缓冲,local-only)。roadmap 4-1。本地表既是网络不稳时的上报缓冲,也是 dev 期读出口(根治 v20/v25「只写不读=死表」教训)。opt-in 默认关(settings.telemetry_opt_in 只认显式 'true');install 级匿名(随机 UUID)、props 只存数值/枚举、绝不含 word/句子/URL/user_id → install 级非 user 级,不进 auth.rs 换用户清理。命令 telemetry_record(no-op-when-off)+ telemetry_flush(fire-and-forget,失败静默留待下次)。冻结后首个 append-only 新表 → golden 测试 flattened_baseline_matches_terminal 收窄到 FROZEN_BASELINE_MAX_VERSION=3。Supabase 建表/RLS + admin 看板消费均已于 2026-07-29 完成
v29recommended_articles: ALTER ADD word_count + body_text(roadmap 3-1 Phase 2 正文管线,local-only 缓存列)。analyze-articles 抓正文后下发,客户端算全文级在学词复现 + 词汇覆盖难度
v30reading_notes: ALTER ADD deleted_at(四级删除闭环最后一级,cross-end/19)。此前是 10 张同步表里唯一没有墓碑列的表 —— 删除信号在整条链路上无处承载。delete_reading_note 从硬删改单事务软删四级(不再依赖 FK CASCADE:rusqlite 通道 PRAGMA foreign_keys 本就 OFF)。Supabase 侧 ALTER 已于 2026-08-03 先行
v31word_encounters 表新建(候选词跨页复现计数,local-only)。roadmap 3-2 待发现词价值排序的第四维信号 —— 该维在本仓此前零数据源(word_page_links 只记已存词、reading_log 只存聚合)。聚合表非明细表(行数被候选池封顶 ≤~12k;明细表在重度用户身上是 ~1M 行/年);last_page_url 作去重闸挡 SPA 重跑(UPSERT 用 CASE WHEN last_page_url IS ?3 的 NULL-safe 相等);per-user 走 user_id 过滤沿 v27 先例、不进换用户清理,未登录时命令 no-op(不写 NULL user_id 行,避开「UNIQUE 里 NULL 互不相等」坑)。🔒 接受的近似A→B→A 回访会重复计数 → UI 文案只许说「读到过 N 次」,不许说「N 篇文章」不进同步(RVH 无浏览概念)。详见 docs/plans/archive/discovery-topn-ranking-plan.md
v32epub_positions 表新建(EPUB 章内滚动偏移,local-only)。backlog B4 剩余部分 —— 「恢复到章」早已零 migration 做完,章内没有现成载体(实测最长章可滚 47 屏)。存 block_idx + block_frac(结构量)而非像素:重排版实测把文档撑 +85%;也不存锚点 id —— 实测 Standard Ebooks 每章只有 1 个 id(等于只能恢复到章),而 <p> 密度反过来。新表而非 reading_pages 加列:后者的行只在存词/划线时产生,而本条场景恰是只读不存词;新表也保证不 touch last_opened_at → 「继续阅读」排序零变化。块枚举口径只有一处(content-script features/epub-anchor.js::collectPositionBlocks,存取共用;按 token 形式 [class^="rb-"],[class*=" rb-"] 排除自注入 UI —— 用 *="rb-" 会把正文 class="verb-list" 整块吞掉)。🔒 两道非显然的闸:① 保存要「用户动过」「真滚了」才武装(只判前者会把 ⌘F 算进来、用章首盖掉真实位置;只判后者会把加载期原生锚点滚动算进来 —— 都是自我覆盖);② 章内落点经 __RB_EPUB_META.startPos 只捎带一次并立刻消费(该 meta 会因滚动联动 replaceState 反复重注入,不消费 = 每跨一个目录锚点把人拽回原处)。不进同步(RVH 无 EPUB 概念)。详见 docs/plans/archive/epub-intra-chapter-position-plan.md
v33epub_positions: ALTER ADD page_name(让「继续阅读」显示真正读到的那一章)。v32 把位置存下来了,但「继续阅读」那一行仍全部读 reading_pages,而那张表的行只在存词/划线时产生 —— 实测章节名和时间两个都错。章名存下来而不是现算,是因为由 source_ref 锚点反查章名需要那本书的目录(列表查询里让 Rust 去解压 EPUB 显然不行),而页内脚本存位置那一刻手上正好有现成的 effectiveSourceTitle()只改显示不改成员资格:「继续阅读」仍只收存过词的书(engaged vs 全量导航的语义分野)。ALTER-ADD、local-only、不进同步、不动 schema.sql
v34vocabulary: CREATE INDEX idx_vocab_tagged ON vocabulary(word) WHERE word_tags IS NOT NULL —— 纯性能设施,服务专名治理 L-A(db/vocab_scope.rs 的「排除纯专名」判据,7 条查询共用)。加它的原因是那条判据实测把发现页热路径从 2ms 拖到 308ms:根因不是判据算得慢,是行读变贵 —— 原本走 idx_vocab_cefr 的索引覆盖扫描(word+cefr 索引里全有、不必读行),判据一碰 word_tags/pos_definitions 就每行读整行,而 pos_definitions 是整棵释义树、表 55MB(光 json_valid(pos_definitions) 一项 239ms)。三种纯 SQL 改写实测全在 270-300ms,瓶颈是行读取本身。本索引只覆盖 ~1102 行(全库 12291),让「算出那 584 个纯专名」变成小索引扫:dict 载入 308→65ms、发现候选池 150→28ms、排除集 278→20ms,索引体积 156KB。选索引而非物化列ADD COLUMN learnable):物化 = 派生状态第二本账,reseed/backfill 忘记重算就静默过期且无报错;索引由 SQLite 自维护。迁移没跑成只是慢不是错(判据不依赖索引存在)→ 无需 init_db_state 兜底建表,与 v26 domain_zoom(缺列直接查询失败)不同。CREATE-INDEX、local-only、不进同步、不动 schema.sql

压平记录(开发期策略:不保留中间 migration;重装 / 删库后直接从 v1 baseline 创建终态 schema。历史细节见 git log):

  • 2026-04-12:snapshot 之前的 v17-v35 增量合并进 v1 baseline。
  • 2026-04-21:阶段 B/C 引入的 v4-v8 增量(drop word_contexts / source_type 回填 / storage_path / reference_words 重建 / favorite_sites+rss+domain_prefs 重建)吸收到 v1 baseline。