Cherry Studio v2 一次性数据迁移引擎深度解析:从 Redux/Dexie 到 SQLite 的架构设计与实战指南
2026/9/20 7:45:21 网站建设 项目流程

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文件:

  1. VersionService 自 v1.7 起内嵌,每当版本变化就在{userData}/写入一个version.log文件。
  2. v2 首次启动时,v2MigrationGate.ts通过MigrationPaths.versionLogFile读取该文件(该路径基于考虑了 v1 自定义目录的解析后 userData 路径)。
  3. 若上一版本过旧、缺失,或用户跳过了 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 直接迁移目标的版本线。

判定规则(按顺序):

  1. version.log且无上一版本 → 判定为从未运行过内嵌 VersionService 的 v1 版本,阻止(no_version_log);
  2. 上一版本低于1.9.12→ 阻止(v1_too_old);
  3. 上一版本是 v1.x(或早于 2.0.0)且当前版本 ≥2.1.0→ 判定跳过了迁移网关线,阻止(v2_gateway_skipped);
  4. version.log存在但没有解析出上一版本(重试或单条目场景)→ 放行;
  5. 其余情况放行。

阻止规则一览

场景阻止原因用户操作
version.log(v1 < 1.7 用户)no_version_log先安装 v1.last 并运行一次,再安装最新 v2.0.x 版本
上一版本 < 1.9.12v1_too_old先升级到 v1.last
上一版本是 v1.x 但当前版本 ≥ 2.1.0v2_gateway_skipped先安装最新 v2.0.x 完成迁移,再升级到当前版本

当判定阻止时,getBlockMessage会生成用户可读的提示文案(英文硬编码,与门禁的dialog.showErrorBox用法一致)。

预发布版本的处理

v2.0.0 预发布版本(alpha/beta/rc)按 semver 语义被视作2.0.0 之前,因此允许作为从 v1.last 迁移的目标:门禁检查会对currentVersionsemver.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_statekey = 'migration_v2_status'记录状态(见 migrationStatus.ts),状态值包含statuscompleted/failed/in_progress)、migratedFromV1、时间戳与错误信息。
  • 运行前清空新架构表verifyAndClearNewTables会先检查MIGRATION_TARGET_TABLES(所有迁移写入的表清单)是否非空并告警,然后在事务中clearMigrationData清空全部目标表——该清单同时服务于“重试”和“跳过”两条路径,避免清理范围漂移。注意清空顺序有讲究:子表必须先于父表(如user_model先于user_providermessage先于topictopic先于assistant)。
  • 校验失败即中止validateMigratorResult强制计数校验——若targetCount < sourceCount - skippedCountValidateResult.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提供的路径包括:

  • 基础目录:userDatacherryHome
  • 数据库与数据目录:databaseFile{userData}/Data/cherrystudio.sqlite)、knowledgeBaseDirfilesDataDir
  • 遗留源:versionLogFilelegacyAgentDbFile(v1 独立 agents SQLite)、legacyClaudeConfigDir/legacyClaudeProjectsDircustomMiniAppsFilelegacyConfigFile
  • v2 目标:agentsDataDirclaudeConfigDirclaudeProjectsDiragentSystemWorkspacesDir
  • 导出暂存:migrationTempDirmigrationReduxExportDirmigrationDexieExportDirmigrationLocalStorageExportDir/File
  • 构建期:migrationsFolder(按app.isPackaged解析 Drizzle 迁移脚本目录)。

resolveMigrationPaths()的返回对象还携带userDataChangedinaccessibleLegacyPath(遗留目录不可达时的回退提示,如外置硬盘未挂载)、legacyDataConfirmed(最终解析目录是否真的含 v1 数据,纯属性计算,不受启动顺序影响)和dataLocation(模糊回退自动选中的非默认目录,用于在迁移介绍页展示)。

其中纯函数selectLegacyUserData实现了完整的目录选择策略(按优先级,先命中者生效):

分支条件动作
A0当前 userData 已有非空 sqlitekeep——绝不因模糊猜测放弃已 V2 化的目录
A1config.json 中有精确的 exe→dir 映射redirectinaccessible(权威映射,不做模糊化处理)
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 只读接口)、reduxStateReduxStateReader,按类别的 Redux Persist 导出文件)、dexieExportDexieFileReader,JSON 导出表)、dexieSettingslocalStorageknowledgeVectorSource(知识库向量源)以及legacyHomeConfigLegacyHomeConfigReader,v1~/.cherrystudio/config/config.json,供BootConfigMigrator的 config 文件迁移路径使用)。
  • db:当前 SQLite 连接(drizzle + better-sqlite3)。
  • paths:预计算的MigrationPaths
  • sharedDataMap<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,实现:

  • 元数据idname(UI 显示名)、descriptionorder(越小越先执行)。
  • reset():重置上次运行的实例状态。引擎会复用迁移器实例并在每次run()前调用,保证重试时计数、缓存、预取数据均为干净状态。
  • prepare(ctx):干跑检查、计数与暂存数据,返回PrepareResultsuccessitemCount、非致命warnings/warningMessages)。
  • execute(ctx):执行插入/更新;自行管理事务;通过reportProgress上报进度;结束时用this.assertOwnedForeignKeys(ctx.db, [...])对自己拥有的表做外键自检。
  • validate(ctx):校验计数与完整性,返回带统计信息(sourceCounttargetCountskippedCount)和errors数组的ValidateResult

注册与执行顺序

迁移器在migrators/migratorRegistry.ts中登记,引擎按其order排序执行。当前实现的 16 个迁移器及其执行顺序为:

BootConfigMigratorPreferencesMigratorNoteMigratorMiniAppMigratorMcpServerMigratorProviderModelMigratorAssistantMigratorFileMigratorAgentsMigratorKnowledgeMigratorKnowledgeVectorMigratorChatMigratorAiUsageRecordMigratorPaintingMigratorTranslateMigratorPromptMigrator

注册表是执行集合的权威来源,每个已注册迁移器的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 - skippedCounterrors非空都会导致引擎失败。
  • 保持幂等:引擎运行前会清空目标表,但每个迁移器仍应容忍同一次运行内的重试。
  • 保留 v1 源数据以支持降级兼容:例如AgentsMigrator会把遗留 Agent 文件复制进 v2 布局,但绝不删除agents.db或遗留短 ID 工作区。
  • 路径安全:所有文件系统路径必须来自ctx.paths。如果缺少某路径,把它加进MigrationPaths接口,而不是内联拼接。

外键(Foreign Key)处理策略

这是迁移性能与数据完整性之间的核心平衡点:

外键在整个迁移期间保持关闭(OFF),且不允许单个迁移器自行开关。原因在于 better-sqlite3 在整个进程内维持单一持久连接,因此引擎在MigrationDbService中只设置一次PRAGMA foreign_keys = OFF(必须在applyMigrations之后执行,因为后者会恢复它自己的连接设置为 ON),该设置在整段迁移内持续生效——不存在按事务重连导致重置的情况。这允许批量插入携带尚未解析的引用(如自引用的message.parentId,或由更晚迁移器解析的跨域引用)。

完整性校验分两层:

  1. 迁移器自检:每个迁移器在execute()结束时调用this.assertOwnedForeignKeys(ctx.db, [...])对自己拥有的表执行PRAGMA foreign_key_check(<table>),尽早暴露归属于本域的外键错误,便于准确定位责任方。
  2. 引擎兜底:全部迁移器完成后,MigrationEngine.verifyForeignKeys执行全库PRAGMA foreign_key_check,扫描所有表,一旦发现违规即抛出含违规样本的错误。

自检范围原则:只传入“当前迁移器完成时其外键已被完全解析”的表。排除由更晚迁移器解析的引用——例如assistant_knowledge_base.knowledgeBaseIdAssistantMigrator写入,但只有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 导出到内存以便同步访问,缺失时降级为警告日志。另有DexieSettingsReaderLocalStorageReaderKnowledgeVectorSourceReader等补充读取器,均有对应单元测试覆盖。

窗口与 IPC 集成

MigrationIpcHandler

window/MigrationIpcHandler.ts为迁移 UI 暴露 IPC 通道(通道名定义于共享类型中的MigrationIpcChannels):

  • 状态查询migration:check-neededmigration:get-progressmigration:get-last-error
  • 流程控制migration:startmigration:prepare-export(准备导出路径并重置 Redux/Dexie/localStorage 暂存目录、校验导出写入)、migration:start-migration(启动引擎)、migration:retrymigration:cancelmigration:restartmigration:skip-migration
  • 进度广播(主 → 渲染):migration:progressmigration:export-progress
  • 文件传输(渲染 → 主):migration:write-export-filemigration:save-diagnostic-bundlemigration:show-diagnostic-bundle-in-folder
  • 窗口控制migration:minimizemigration:close-window、关闭确认三连(confirm-close/confirm-quit/cancel-close)。

实现上的几个关键点:所有处理器通过validateSender校验发送方(assertMigrationWindowSender),防止未授权方调用;引擎以inFlightMigration防止并发启动(重复启动返回Migration is already in progress.);打开 v1 下载页需要走主进程(迁移窗口使用仅含 ipcRenderer 的simplestpreload),主进程根据界面语言选择 CN/Global 下载站;migration:report-export-stagemigration:report-error用于渲染进程导出 OOM 等故障的主进程面包屑与终态错误同步。

MigrationWindowManager

window/MigrationWindowManager.ts创建无边框迁移窗口,负责生命周期管理,并在生产环境完成后给出重启指引。

新增迁移器的实现清单

参照原文档与源码约定,新增一个领域迁移器的标准流程:

  1. (如需要)在migrators/mappings/下添加映射定义;
  2. 继承BaseMigrator实现prepare/execute/validate,包含显式计数、批量插入与完整性自检;
  3. 通过reportProgress接通进度上报,让 UI 显示每个迁移器的进度;
  4. migrators/migratorRegistry.ts中以正确的order注册迁移器;
  5. 目标表建好后,把新表加入MigrationEngine.verifyAndClearNewTablesMIGRATION_TARGET_TABLES清单(注意子表先于父表的清空顺序);
  6. execute()末尾通过this.assertOwnedForeignKeys(ctx.db, [...ownedTables])自检外键,排除跨域延迟引用与共享多态表(见外键约定);不要自行开关PRAGMA foreign_keys——引擎已在整个迁移期间将其保持 OFF;
  7. 记录不明显的业务不变量与转换依据,而非复述实现细节;
  8. 当迁移器存在调用方无法从通用契约推断的源格式、恢复或转换规则时,创建或更新migrators/README-<MigratorName>.md

可排序资源的 OrderKey 打标

遗留 Redux/Dexie → SQLite 迁移中,所有可排序资源必须为插入的每一行生成order_key。v2 迁移层在 src/main/data/migration/v2/utils/orderKey.ts 提供了一对纯函数,不触碰数据库——接收已拍平的数组,返回带上orderKey的同一批行:

辅助函数形态适用场景
assignOrderKeysInSequence(rows)为每行返回一个单调递增的orderKey整表排序(如mcp_serveruser_providerminiapp
assignOrderKeysByScope(rows, getScope)按作用域键分组,每个桶独立打标(各桶独立键空间)分区表(如user_model.providerIdgroup.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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询