Cherry Studio v2 一次性数据迁移引擎深度解析:从 Redux/Dexie 到 SQLite 的架构设计与实战指南
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
本文是 Cherry Studio(CherryHQ/cherry-studio)数据层 V2 迁移系统的技术指南,围绕 v2-migration-guide.md 展开,深入剖析从 v1 遗留的 Dexie + Redux Persist 存储一次性迁移到 SQLite 新架构的完整方案:包括线性升级门禁、迁移引擎与迁移器(Migrator)契约、外键完整性策略、数据源读取器、IPC 窗口集成与 orderKey 排序键打标机制。读完本文,你将掌握该迁移模块的架构脉络、各核心组件的实现原理,以及如何按照项目既有约定新增一个领域迁移器的完整实践路径。
背景:为什么需要一次性的 v1 → v2 迁移
Cherry Studio v2 对数据层进行了根本性重构:v1 时代的数据分散在Dexie(IndexedDB)、Redux Persist 导出文件与electron-store(config.json)中,而 v2 统一收敛到SQLite 数据库({userData}/Data/cherrystudio.sqlite)。为了不丢失用户历史数据,v2 引入了一个一次性(one-shot)迁移引擎,负责把上述遗留存储中的数据按领域拆解、转换并写入 SQLite 新表。
从源码看,整个迁移模块集中在 src/main/data/migration/v2(主进程编排、数据访问、迁移器插件与 IPC 入口)和 src/shared/data/migration/v2/types.ts(主进程与渲染进程共享的阶段、结果与校验统计类型)。渲染进程的迁移窗口只是 UI 外壳,真正的编排逻辑全部在主进程完成。
线性升级路径与版本门禁
强制线性升级路径
迁移系统强制一条线性升级路径以保证数据完整性:
v1.old → v1.last (≥1.9.12) → v2.0.x → v2.1+为什么是线性路径?
v2.0.0 引入了从 Redux/Dexie 到 SQLite 的一次性数据迁移,且每个 v2.0.x 补丁版本都完整保留该迁移逻辑并附带修复。如果要从每一个 v1 版本直接迁移,测试矩阵会膨胀为 O(n²)。通过要求所有用户先升级到最终 v1 版本,迁移代码只需处理一种确定的源数据格式,大大降低了兼容面。
门禁如何工作
整个门禁依赖version.log文件:
- VersionService 自 v1.7 起内嵌,每当版本变化就在
{userData}/写入一个version.log文件。 - v2 首次启动时,
v2MigrationGate.ts通过MigrationPaths.versionLogFile读取该文件(该路径基于考虑了 v1 自定义目录的解析后 userData 路径)。 - 若上一版本过旧、缺失,或用户跳过了 v2.0.x 迁移线,门禁会弹出错误对话框并退出,用户不会进入迁移 UI。
从 versionPolicy.ts 可以看到核心判定函数checkUpgradePathCompatibility的实现规则:
V1_REQUIRED_VERSION = '1.9.12':v2 数据迁移支持的最低 v1 版本;V2_GATEWAY_VERSION = '2.0.0':v2.0.x 迁移网关线的起始版本;V2_DIRECT_MIGRATION_CEILING = '2.1.0':第一个不能作为 v1→v2 直接迁移目标的版本线。
判定规则(按顺序):
- 无
version.log且无上一版本 → 判定为从未运行过内嵌 VersionService 的 v1 版本,阻止(no_version_log); - 上一版本低于
1.9.12→ 阻止(v1_too_old); - 上一版本是 v1.x(或早于 2.0.0)且当前版本 ≥
2.1.0→ 判定跳过了迁移网关线,阻止(v2_gateway_skipped); version.log存在但没有解析出上一版本(重试或单条目场景)→ 放行;- 其余情况放行。
阻止规则一览
| 场景 | 阻止原因 | 用户操作 |
|---|---|---|
无version.log(v1 < 1.7 用户) | no_version_log | 先安装 v1.last 并运行一次,再安装最新 v2.0.x 版本 |
| 上一版本 < 1.9.12 | v1_too_old | 先升级到 v1.last |
| 上一版本是 v1.x 但当前版本 ≥ 2.1.0 | v2_gateway_skipped | 先安装最新 v2.0.x 完成迁移,再升级到当前版本 |
当判定阻止时,getBlockMessage会生成用户可读的提示文案(英文硬编码,与门禁的dialog.showErrorBox用法一致)。
预发布版本的处理
v2.0.0 预发布版本(alpha/beta/rc)按 semver 语义被视作2.0.0 之前,因此允许作为从 v1.last 迁移的目标:门禁检查会对currentVersion做semver.coerce()处理,使2.0.0-alpha被视为2.0.0从而放行。但previousVersion不做 coerce——2.0.0-beta仍被视作“未通过网关”。预发布之间的升级(alpha→beta→rc→2.0.0)之所以安全,是因为首次成功运行后迁移状态即为completed,后续needsMigration()返回 false。
经过验证的直接迁移目标是完整的v2.0.x版本线;从 v2.1.0 起,后续版本在迁移兼容性被显式验证之前,都会被阻止作为首次迁移目标。
与自动更新器的关系
自动更新器(AppUpdaterService)会把已安装版本等客户端元数据上报给受管发布服务,由其选择 OTA 目标并强制执行升级网关。而迁移门禁是另一道独立的安全网,针对手动下载安装版本的用户。两套系统都强制兼容的升级路径,但彼此独立运作。
目录布局
src/main/data/migration/v2/ ├── core/ # Engine + shared context ├── migrators/ # Domain-specific migrators and mappings ├── utils/ # Data source readers (Redux, Dexie, streaming JSON) ├── window/ # IPC handlers + migration window manager └── index.ts # Public exports for main process与文档描述一致,源码目录中还包含每个迁移器的README-<MigratorName>.md文档、mappings/映射定义、transformers/转换器,以及覆盖各迁移器的__tests__/测试目录(如 MigrationEngine.test.ts、MigrationPaths.test.ts、versionPolicy.test.ts 等),可用于验证本文所述行为。
核心契约
MigrationEngine:编排中枢
core/MigrationEngine.ts是迁移编排的单一入口(单例migrationEngine),负责:
- 按序协调所有迁移器:
registerMigrators按各迁移器的order字段升序排序,run()逐个执行。 - 向 UI 上报进度:通过
onProgress回调,calculateProgress按“已完成迁移器数 + 当前迁移器内进度”加权计算 0–100 的整体进度,updateProgress携带当前消息与 i18n 键(默认migration.progress.processing)以及每个迁移器的pending/running/completed状态。 - 唯一持久化迁移标记:以
app_state表key = 'migration_v2_status'记录状态(见 migrationStatus.ts),状态值包含status(completed/failed/in_progress)、migratedFromV1、时间戳与错误信息。 - 运行前清空新架构表:
verifyAndClearNewTables会先检查MIGRATION_TARGET_TABLES(所有迁移写入的表清单)是否非空并告警,然后在事务中clearMigrationData清空全部目标表——该清单同时服务于“重试”和“跳过”两条路径,避免清理范围漂移。注意清空顺序有讲究:子表必须先于父表(如user_model先于user_provider、message先于topic、topic先于assistant)。 - 校验失败即中止:
validateMigratorResult强制计数校验——若targetCount < sourceCount - skippedCount或ValidateResult.errors非空,直接抛错终止整个迁移;全局外键校验失败同样中止。 - 运行后清理临时文件:无论成功失败,
finally中都会删除migration_temp下的 Redux/Dexie/localStorage 导出目录,避免失败重试时遗留大体积导出快照。
needsMigration()的判定逻辑值得注意:优先读取已存储的迁移状态;若无状态(首次启动),则以legacyDataConfirmed(门禁路径解析的权威信号,能识别version.log、Chromium 存储、config 键等标记)或hasLegacyData()(electron-store 探测)二者之一为准,防止“重定向到的自定义目录恰好 electron-store 为空”被误判为新安装而永久锁定为 completed。全新安装(无任何遗留数据)会直接markCompleted(false)跳过迁移。
MigrationPaths:路径安全约定
core/MigrationPaths.ts定义了MigrationPaths(冻结的预计算路径对象)和resolveMigrationPaths()。它会在迁移门禁入口被调用一次(引擎初始化之前),从~/.cherrystudio/config/config.json检测 v1 遗留的 userData 目录。
这是全模块最关键的约定之一:所有迁移代码一律使用ctx.paths,绝不调用app.getPath()或自行path.join拼路径。原因在文件头注释中写得很直白:v1 用户若通过~/.cherrystudio/config/config.json配置了自定义 userData 目录,v2 首次启动时app.getPath('userData')返回的是 Electron 默认值——因为resolveUserDataLocation()尚未把遗留配置迁移进 boot-config.json——直接使用会导致数据丢失。
MigrationPaths提供的路径包括:
- 基础目录:
userData、cherryHome; - 数据库与数据目录:
databaseFile({userData}/Data/cherrystudio.sqlite)、knowledgeBaseDir、filesDataDir; - 遗留源:
versionLogFile、legacyAgentDbFile(v1 独立 agents SQLite)、legacyClaudeConfigDir/legacyClaudeProjectsDir、customMiniAppsFile、legacyConfigFile; - v2 目标:
agentsDataDir、claudeConfigDir、claudeProjectsDir、agentSystemWorkspacesDir; - 导出暂存:
migrationTempDir及migrationReduxExportDir、migrationDexieExportDir、migrationLocalStorageExportDir/File; - 构建期:
migrationsFolder(按app.isPackaged解析 Drizzle 迁移脚本目录)。
resolveMigrationPaths()的返回对象还携带userDataChanged、inaccessibleLegacyPath(遗留目录不可达时的回退提示,如外置硬盘未挂载)、legacyDataConfirmed(最终解析目录是否真的含 v1 数据,纯属性计算,不受启动顺序影响)和dataLocation(模糊回退自动选中的非默认目录,用于在迁移介绍页展示)。
其中纯函数selectLegacyUserData实现了完整的目录选择策略(按优先级,先命中者生效):
| 分支 | 条件 | 动作 |
|---|---|---|
| A0 | 当前 userData 已有非空 sqlite | keep——绝不因模糊猜测放弃已 V2 化的目录 |
| A1 | config.json 中有精确的 exe→dir 映射 | redirect或inaccessible(权威映射,不做模糊化处理) |
| B1 | 存在可用且含 v1 数据且版本合格的目录 | redirect最新使用目录(带notice) |
| B2 | 存在候选但版本不合格 | 仍redirect(交由版本门禁用该目录自己的 version.log 阻止) |
| B3 | 无候选但记录目录不可达 | inaccessible,提示用户而非静默全新启动 |
| B4 | 无可恢复数据 | default,走正常流程 |
此外readLegacyEntries兼容 v1 的两种历史形态:字符串{ "appDataPath": "/path" }(适用于所有可执行文件,会合成一条以当前 exe 为键的精确条目)与数组{ "appDataPath": [{ executablePath, dataPath }, ...] }(原样返回用于精确匹配与模糊枚举)。pinUserDataPath会把解析结果写入 boot-config,且采用严格持久化(persist()写失败会抛错),确保下次启动能直接解析到正确目录。
MigrationContext:迁移器共享上下文
core/MigrationContext.ts构建传给每个迁移器的共享上下文MigrationContext:
sources:各数据源读取器——electronStore(electron-store 只读接口)、reduxState(ReduxStateReader,按类别的 Redux Persist 导出文件)、dexieExport(DexieFileReader,JSON 导出表)、dexieSettings、localStorage、knowledgeVectorSource(知识库向量源)以及legacyHomeConfig(LegacyHomeConfigReader,v1~/.cherrystudio/config/config.json,供BootConfigMigrator的 config 文件迁移路径使用)。db:当前 SQLite 连接(drizzle + better-sqlite3)。paths:预计算的MigrationPaths。sharedData:Map<string, unknown>,用于迁移器之间传递跨域信息(如 assistant → chat 的 ID 引用)。logger:scoped 到迁移的日志服务。
共享类型定义
src/shared/data/migration/v2/types.ts 定义了主进程与渲染进程共享的完整类型体系:迁移阶段(version_incompatible/introduction/migration/completed/error)、迁移器状态(pending/running/completed/failed)、三阶段结果(PrepareResult/ExecuteResult/ValidateResult)、迁移进度、摘要与诊断数据,以及全部 IPC 通道名常量MigrationIpcChannels。
迁移器(Migrator)体系
基础契约
每个迁移器继承migrators/BaseMigrator.ts,实现:
- 元数据:
id、name(UI 显示名)、description、order(越小越先执行)。 reset():重置上次运行的实例状态。引擎会复用迁移器实例并在每次run()前调用,保证重试时计数、缓存、预取数据均为干净状态。prepare(ctx):干跑检查、计数与暂存数据,返回PrepareResult(success、itemCount、非致命warnings/warningMessages)。execute(ctx):执行插入/更新;自行管理事务;通过reportProgress上报进度;结束时用this.assertOwnedForeignKeys(ctx.db, [...])对自己拥有的表做外键自检。validate(ctx):校验计数与完整性,返回带统计信息(sourceCount、targetCount、skippedCount)和errors数组的ValidateResult。
注册与执行顺序
迁移器在migrators/migratorRegistry.ts中登记,引擎按其order排序执行。当前实现的 16 个迁移器及其执行顺序为:
BootConfigMigrator→PreferencesMigrator→NoteMigrator→MiniAppMigrator→McpServerMigrator→ProviderModelMigrator→AssistantMigrator→FileMigrator→AgentsMigrator→KnowledgeMigrator→KnowledgeVectorMigrator→ChatMigrator→AiUsageRecordMigrator→PaintingMigrator→TranslateMigrator→PromptMigrator
注册表是执行集合的权威来源,每个已注册迁移器的order是顺序的权威来源;各领域额外的源码、转换或恢复细节记录在migrators/README-<name>.md中(如 README-ChatMigrator.md、README-AgentsMigrator.md)。
BootConfigMigrator是唯一的文件目标例外:它把早期启动设置写入bootConfigService,而其余迁移器写入 SQLite 或按知识库生成的 Index 产物。
迁移器约定
- 所有日志通过
loggerService输出并带迁移器专属 context。 - 使用
MigrationContext.sources访问数据,而非直接读原始文件/存储。 - 用
sharedData在迁移器间传递 ID 或查找表(如 assistant → chat 引用),避免重复读取源。 - 大体积 Dexie 导出使用
JsonStreamReader流式读取并批量插入,避免内存尖峰。 - 整个迁移过程外键保持关闭(详见下节)。
- 计数校验是强制的:
targetCount < sourceCount - skippedCount或errors非空都会导致引擎失败。 - 保持幂等:引擎运行前会清空目标表,但每个迁移器仍应容忍同一次运行内的重试。
- 保留 v1 源数据以支持降级兼容:例如
AgentsMigrator会把遗留 Agent 文件复制进 v2 布局,但绝不删除agents.db或遗留短 ID 工作区。 - 路径安全:所有文件系统路径必须来自
ctx.paths。如果缺少某路径,把它加进MigrationPaths接口,而不是内联拼接。
外键(Foreign Key)处理策略
这是迁移性能与数据完整性之间的核心平衡点:
外键在整个迁移期间保持关闭(OFF),且不允许单个迁移器自行开关。原因在于 better-sqlite3 在整个进程内维持单一持久连接,因此引擎在MigrationDbService中只设置一次PRAGMA foreign_keys = OFF(必须在applyMigrations之后执行,因为后者会恢复它自己的连接设置为 ON),该设置在整段迁移内持续生效——不存在按事务重连导致重置的情况。这允许批量插入携带尚未解析的引用(如自引用的message.parentId,或由更晚迁移器解析的跨域引用)。
完整性校验分两层:
- 迁移器自检:每个迁移器在
execute()结束时调用this.assertOwnedForeignKeys(ctx.db, [...])对自己拥有的表执行PRAGMA foreign_key_check(<table>),尽早暴露归属于本域的外键错误,便于准确定位责任方。 - 引擎兜底:全部迁移器完成后,
MigrationEngine.verifyForeignKeys执行全库PRAGMA foreign_key_check,扫描所有表,一旦发现违规即抛出含违规样本的错误。
自检范围原则:只传入“当前迁移器完成时其外键已被完全解析”的表。排除由更晚迁移器解析的引用——例如assistant_knowledge_base.knowledgeBaseId由AssistantMigrator写入,但只有KnowledgeMigrator完成重映射/剪枝后才有效,因此该表由KnowledgeMigrator自检而非AssistantMigrator。专属文件关联表(如chat_message_file_ref)可由同时拥有源行与引用行的迁移器自检。
工具层:数据源读取器
utils/目录提供了四个核心读取器:
ReduxStateReader.ts:对分类存放的 Redux Persist 导出文件提供安全的按需访问(支持点路径查找)。DexieFileReader.ts:读取导出的 Dexie JSON 表,支持大表流式读取。JsonStreamReader.ts:针对超大数组的流式读取器,内置批量处理、计数与采样辅助。LegacyHomeConfigReader.ts:同步读取 v1~/.cherrystudio/config/config.json,并将其appDataPath字段归一化为Record<executablePath, dataPath> | null——同时兼容遗留的字符串形态与当前的{ executablePath, dataPath }[]数组形态。仅供BootConfigMigrator的'configfile'数据源使用。
MigrationContext构建时会预加载 Dexiesettings表与 localStorage 导出到内存以便同步访问,缺失时降级为警告日志。另有DexieSettingsReader、LocalStorageReader、KnowledgeVectorSourceReader等补充读取器,均有对应单元测试覆盖。
窗口与 IPC 集成
MigrationIpcHandler
window/MigrationIpcHandler.ts为迁移 UI 暴露 IPC 通道(通道名定义于共享类型中的MigrationIpcChannels):
- 状态查询:
migration:check-needed、migration:get-progress、migration:get-last-error; - 流程控制:
migration:start、migration:prepare-export(准备导出路径并重置 Redux/Dexie/localStorage 暂存目录、校验导出写入)、migration:start-migration(启动引擎)、migration:retry、migration:cancel、migration:restart、migration:skip-migration; - 进度广播(主 → 渲染):
migration:progress、migration:export-progress; - 文件传输(渲染 → 主):
migration:write-export-file、migration:save-diagnostic-bundle、migration:show-diagnostic-bundle-in-folder; - 窗口控制:
migration:minimize、migration:close-window、关闭确认三连(confirm-close/confirm-quit/cancel-close)。
实现上的几个关键点:所有处理器通过validateSender校验发送方(assertMigrationWindowSender),防止未授权方调用;引擎以inFlightMigration防止并发启动(重复启动返回Migration is already in progress.);打开 v1 下载页需要走主进程(迁移窗口使用仅含 ipcRenderer 的simplestpreload),主进程根据界面语言选择 CN/Global 下载站;migration:report-export-stage与migration:report-error用于渲染进程导出 OOM 等故障的主进程面包屑与终态错误同步。
MigrationWindowManager
window/MigrationWindowManager.ts创建无边框迁移窗口,负责生命周期管理,并在生产环境完成后给出重启指引。
新增迁移器的实现清单
参照原文档与源码约定,新增一个领域迁移器的标准流程:
- (如需要)在
migrators/mappings/下添加映射定义; - 继承
BaseMigrator实现prepare/execute/validate,包含显式计数、批量插入与完整性自检; - 通过
reportProgress接通进度上报,让 UI 显示每个迁移器的进度; - 在
migrators/migratorRegistry.ts中以正确的order注册迁移器; - 目标表建好后,把新表加入
MigrationEngine.verifyAndClearNewTables的MIGRATION_TARGET_TABLES清单(注意子表先于父表的清空顺序); - 在
execute()末尾通过this.assertOwnedForeignKeys(ctx.db, [...ownedTables])自检外键,排除跨域延迟引用与共享多态表(见外键约定);不要自行开关PRAGMA foreign_keys——引擎已在整个迁移期间将其保持 OFF; - 记录不明显的业务不变量与转换依据,而非复述实现细节;
- 当迁移器存在调用方无法从通用契约推断的源格式、恢复或转换规则时,创建或更新
migrators/README-<MigratorName>.md。
可排序资源的 OrderKey 打标
遗留 Redux/Dexie → SQLite 迁移中,所有可排序资源必须为插入的每一行生成order_key。v2 迁移层在 src/main/data/migration/v2/utils/orderKey.ts 提供了一对纯函数,不触碰数据库——接收已拍平的数组,返回带上orderKey的同一批行:
| 辅助函数 | 形态 | 适用场景 |
|---|---|---|
assignOrderKeysInSequence(rows) | 为每行返回一个单调递增的orderKey | 整表排序(如mcp_server、user_provider、miniapp) |
assignOrderKeysByScope(rows, getScope) | 按作用域键分组,每个桶独立打标(各桶独立键空间) | 分区表(如user_model.providerId、group.entityType) |
模式——先拍平、后打标:保持transform*函数纯净(不接收index参数、不传sortOrder参数),先把遗留源拍平成数组,再对整批数组打标:
import { assignOrderKeysByScope, assignOrderKeysInSequence } from '@data/migration/v2/utils/orderKey' // 之前——每个 transform 接收 index 并产出 sortOrder const rows = legacyServers.map((src, i) => transformMcpServer(src, i).row) // 之后——transforms 保持纯净;键在拍平之后统一分配 const rows = legacyServers.map((src) => transformMcpServerV2(src).row) const stamped = assignOrderKeysInSequence(rows) tx.insert(mcpServerTable).values(stamped).run() // 分区示例——每个 providerId 拥有独立键空间 const stamped = assignOrderKeysByScope(userModels, (m) => m.providerId)导入规则——绝不直接引用fractional-indexing:迁移器辅助函数委托给 src/main/data/services/utils/orderKey.ts 导出的generateOrderKeySequence,这是该库唯一被认可的集成点。迁移器代码、迁移脚本与 drizzle 自定义迁移回调都必须从这个服务层包装重新导入,以保证库边界可审计,且后续更换字符集或替换实现时只有一个改动位置。
迁移窗口之外的运行时对应物(insertWithOrderKey/insertManyWithOrderKey/applyMoves/resetOrder),参见数据排序指南——服务端辅助函数。
小结
Cherry Studio 的 v2 一次性迁移系统是一套设计严谨的“数据搬迁”架构:用线性升级门禁把源数据格式收敛为单一形态,用MigrationPaths 路径安全约定规避 v1 自定义目录的数据丢失风险,用迁移器注册表 + 三阶段契约把 16 个业务领域解耦为可独立开发、测试、文档化的单元,用全局外键关闭 + 双层校验平衡批量插入性能与引用完整性,再通过IPC 窗口 + 进度流为用户提供可控、可重试、可跳过的迁移体验。对于需要扩展该模块的开发者,本文的实现清单、外键自检边界与 orderKey 打标规范是三条最值得遵循的纪律。
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考