跳到主要内容

领域模型与本地数据库:远端事实如何变成本地投影

一、数据库 schema 就是产品模型的另一份目录

packages/internal/database/src/schemas/index.ts 用 Drizzle 定义 SQLite 表。它不是简单的缓存表,而是客户端能够独立恢复界面的本地投影。

核心表可以压缩成一张关系图:

二、为什么 Subscription 单独成表

如果用户只是“订阅 Feed”,可以把 userId 直接放在 Feed 上。但 Folo 还要支持:

  • 同一个 Feed 进入不同 List;
  • List 和 Inbox 也作为导航入口;
  • 每个订阅有独立 viewcategoryhideFromTimelineisPrivate
  • 未读数要对订阅入口进行汇总。

因此 subscriptionsTable 使用 type 联合三种目标,允许上层把它们统一处理,又保留不同目标的 ID。它是“导航对象”的实现,而不是纯关系表。

三、Entry 的原始事实与派生结果

entriesTable 同时保存 contentdescription、媒体、附件、作者、语言、read 等信息。与内容增强有关的结果被拆开:

  • readabilityContentreadabilityUpdatedAt:文章正文抽取的本地缓存;
  • summariesTable:按 entryId + language 唯一的摘要;
  • translationsTable:按 entryId + language 唯一的翻译;
  • imagesTable:按 URL 缓存色彩分析;
  • ai_chat_sessions / ai_chat_messages:独立于条目本体的对话历史。

这是一种“事实不覆盖、派生可重建”的存储纪律。翻译失败不会破坏原文;摘要服务换模型时可以重算;阅读器可以在没有 Readability 结果时回退到 Feed 内容。

四、三端为什么需要三种 SQLite 入口

Web/桌面渲染器:wa-sqlite + IndexedDB VFS

db.desktop.ts 使用 wa-sqliteIDBMirrorVFS,把 SQLite 数据文件映射到 IndexedDB。Drizzle 通过 sqlite-proxy 接收 SQL,ResourceLock 防止多个异步查询同时操作底层 SQLite。

它还提供 getDBFileexportDBdeleteDB,因此本地数据库不是黑盒缓存,而是可导出、可诊断、可删除的用户数据。

React Native:Expo SQLite

db.rn.ts 使用 expo-sqlite 的同步打开接口和 drizzle-orm/expo-sqlite。移动端无需 IndexedDB VFS,迁移逻辑单独适配 Expo 的 execSync/getAllSync

共享层:相同 schema,不同 driver

两个入口共享 schemas、迁移文件和上层 Service API。平台只替换数据库驱动,不替换 Feed/Entry 的领域语言。

五、迁移策略的细节

migrator.ts 读取 Drizzle journal,按 m0000 形式找到 SQL,创建 __drizzle_migrations,再根据时间戳决定要执行哪些迁移。Expo 路径还对 ADD COLUMN / DROP COLUMN 做列存在性检查,避免重复迁移因为 SQLite 差异直接失败。

异常时的策略是删除本地 DB 后重建。这是一个强策略,适合“本地投影可从远端重拉”的数据;但它也意味着产品必须确保服务端数据足够完整、用户的离线变更不会只存在于本地。源码中 deleteDB 和导出能力正好为这个取舍提供了运维出口。

六、从远端 DTO 到本地 Model

Store 的 morph/api.ts 定义 APIMorph,将 client-sdk 的 FeedSchemaListSchemaEntryWithFeed 等返回类型转换为本地 FeedModelListModelEntryModel。转换点通常承担三件事:

  1. 补齐本地 UI 需要的默认字段;
  2. 把远端时间/可选字段转成 Store 约定的形式;
  3. 把不同入口的 DTO 统一成同一领域 Model。

这就是客户端稳定性的关键:API 版本变化首先撞到 Morph 层,而不是直接扩散到每个 Card、Hook 和页面。

七、读 schema 时要追的三个问题

  • 一个字段是事实用户状态还是派生缓存
  • 它的唯一性由主键、联合索引还是服务端 ID 保证?
  • 它更新时,哪些 Store 模块、哪些页面和哪些设备会看到变化?

例如摘要使用 (entryId, language) 唯一索引,说明摘要是可多语言缓存,不是 Entry 的单值属性;AI 消息按 (chatId, createdAt) 建索引,说明聊天历史的读取顺序和会话范围是主要查询路径。