Repository Wiki
ChanIok/SpinningMomo

SQLite 数据库与迁移系统

SpinningMomo 以 SQLiteCpp 作为 SQLite 访问层,并配套一套"SQL 文件 → 自动生成 C++ 头文件 → 启动时按版本号顺序执行"的迁移系统:应用启动时读取 AppData 下的 app_version.txt,与应用当前版本比较,筛选并依次执行落在 (last_version, current_version] 区间内的迁移脚本,最后回写版本号。

Purpose and Scope

本页面完整覆盖该子系统的迁移侧实现与数据库侧的架构定位:

  • src/core/migration/migration.hpp / migration.cpp —— 迁移引擎核心(版本文件读写、版本号比较、脚本筛选与执行)
  • scripts/generate-migrations.js —— 开发期代码生成器,把 src/migrations/*.sql 转成 src/core/migration/generated/ 下的 C++ 头文件
  • src/core/migration/scripts/scripts.hpp —— 迁移脚本注册表(scripts::get_all_migrations()),由生成产物聚合而来
  • app_version.txt —— 持久化在应用数据目录中的"上次运行版本"标记
  • 数据库层(SQLiteCpp、线程局部连接、DataMapper)的架构级说明

以下内容有意留给兄弟页面,本页仅交叉引用:

  • 数据库连接管理、DataMapper 的 ORM 行映射实现细节 —— 属于数据库访问层主题,本文只引用 AGENTS.md 中的架构描述,未读取其源码
  • core::AppState 应用状态的内部结构(迁移函数以引用方式接收它)
  • utils::path::GetAppDataFilePath 的路径解析规则
  • core::version::get_app_version() 的取值来源

Overview

解决的问题

桌面应用升级后,本地 SQLite 库结构需要随之演进。该项目没有引入通用 ORM 的迁移框架,而是自建了一套极轻量的机制,设计目标可以概括为:

  1. 迁移即纯 SQL:开发者只需在 src/migrations/ 下维护带版本注释的 .sql 文件,不用手写 C++ 迁移代码。
  2. 编译期内嵌:通过 scripts/generate-migrations.js 把 SQL 编译成 C++ 头文件,随二进制分发,运行期不依赖任何外部 SQL 资源文件。
  3. 版本区间执行:只执行 target_version 落在 (上一次版本, 当前版本] 的脚本,天然支持跨多版本升级。
  4. 区分全新安装:首次启动(版本文件不存在,等价于 0.0.0.0)时,可跳过标记为"不需要在全新安装时执行"的历史脚本,避免对新建库重复执行早期补丁。

关键概念

概念位置说明
版本文件app_version.txt(AppData 目录)单行文本,记录上次成功运行的 4 段版本号
4 段版本号x.y.z.wcompare_versions 按 4 段整数逐段比较,不足补 0
全新安装last_version == "0.0.0.0"版本文件缺失时直接返回 0.0.0.0,不视为错误
MigrationScriptscripts::get_all_migrations() 返回的脚本描述从 migration.cpp 的调用点可确认其包含 target_version、description、run_on_fresh_install、migration_fn 四个成员
迁移函数script->migration_fn(app_state)接收 core::AppState&,返回 std::expected 风格结果
生成产物src/core/migration/generated/由 generate-migrations.js 生成,模块名后缀形如 V001

Architecture

Loading diagram...

架构分层解读:

  • 开发期与运行期彻底解耦。SQL 只存在于 src/migrations/,通过 Node 脚本一次性转成 C++ 头文件;运行期没有文件 IO 去读 SQL(唯一涉及的文件是版本标记 app_version.txt)。这使迁移可以随单文件二进制分发。
  • scripts.hpp 是唯一的注册边界。migration.cpp 通过 #include "core/migration/scripts/scripts.hpp" 并调用 scripts::get_all_migrations() 拿到全部脚本(见 migration.cpp:5),对"脚本如何被注册/生成"完全无感知。
  • 迁移函数依赖注入了 AppState 而非直接操作全局状态,便于把数据库句柄等运行时上下文交给迁移逻辑使用。
  • 数据库层(SQLiteCpp、线程局部连接、DataMapper)与迁移系统是"被使用者/使用者"关系:迁移脚本最终落到 SQLite 上,但连接管理细节在 core::database 模块中,本文不展开。

Core Flow:启动时的迁移决策与执行

Loading diagram...

逐步解读(对应 migration.cpp:116-200):

  1. 读取当前版本:来自 core::version::get_app_version()(migration.cpp:119)。
  2. 读取上次版本:get_last_version() 打开版本文件取第一行(migration.cpp:32-38)。文件不存在时返回 "0.0.0.0" 并记录 Version file not found, assuming first launch(migration.cpp:26-29)——这是全新安装的唯一判定依据,文件缺失被设计为正常路径而非错误。
  3. 版本相同则直接返回 true(migration.cpp:132-135),零开销跳过。
  4. 脚本筛选:两个条件的合取(migration.cpp:141-151):
    • 全新安装时跳过 !script.run_on_fresh_install 的脚本;
    • 保留满足 compare_versions(target_version, last_version) > 0 && compare_versions(target_version, current_version) <= 0 的脚本,即左开右闭区间 (last, current]。
  5. 升序排序:std::ranges::sort(migration.cpp:154-156)保证多版本跨级升级时脚本按引入顺序执行——这是 schema 演进正确性的前提。
  6. 空集兜底:找不到脚本时只 warn 不 fail,仍然保存当前版本号(migration.cpp:158-170)。设计意图:版本回退(降级运行)或脚本缺失不应阻断启动,且要让版本文件跟上实际运行的程序版本。
  7. 执行失败即中止:任一脚本失败立即 return false 且不回写版本号(migration.cpp:181-185)——下次启动会再次尝试同一批脚本,实现"失败可重试"的幂等策略基础。
  8. 成功后回写版本(migration.cpp:191-196)。

Usage Examples

生成器入口与注释头(代码生成器)

generate-migrations.js 的自述注释完整定义了输入、输出与切分规则:

javascript
1// 将 src/migrations 目录下的 SQL 文件转换为 C++ 头文件 2// 3// 用法: 4// node scripts/generate-migrations.js 5// 6// 功能: 7// - 读取 src/migrations/*.sql 文件 8// - 解析 SQL 文件中的版本号和描述(从注释中读取) 9// - 按分号分割 SQL 语句 10// - 生成 C++ 头文件到 src/core/migration/generated/

Source: generate-migrations.js

要点:版本号与描述写在 SQL 文件的注释里,由脚本解析;SQL 语句按分号切分后逐条进入生成产物。项目要求在修改 src/migrations/*.sql 后重新运行该命令(见 AGENTS.md)。

生成 C++ 头文件的模板(模块名规则)

生成器为每个迁移生成一个独立模块,版本号补齐为 3 位数字作为模块后缀:

javascript
function generateCppHeader(migrationFile, version, sqlStatements) { const moduleSuffix = `V${String(version).padStart(3, "0")}`;

Source: generate-migrations.js

生成头文件带有明确的禁改标记,指向其源 SQL 文件,形成"SQL 是唯一事实来源"的约定:

javascript
// DO NOT EDIT - This file is generated from // src/migrations/${migrationFile}

Source: generate-migrations.js

目录解析部分确认了输入/输出路径(注意 generated 目录由 core → migration → generated 三段拼接而成):

javascript
const migrationsDir = path.join(projectRoot, "src", "migrations");

Source: generate-migrations.js

版本文件读写(迁移引擎)

get_last_version() 展示了"文件缺失 = 全新安装"的容错路径:

cpp
1auto get_last_version() -> std::expected<std::string, std::string> { 2 auto path_result = get_version_file_path(); 3 if (!path_result) { 4 return std::unexpected("Failed to get version file path: " + path_result.error()); 5 } 6 7 auto path = path_result.value(); 8 9 // 首次启动,版本文件不存在 10 if (!std::filesystem::exists(path)) { 11 Logger().info("Version file not found, assuming first launch"); 12 return "0.0.0.0"; 13 } 14 ... 15 std::string version; 16 std::getline(file, version); 17 18 if (version.empty()) { 19 return std::unexpected("Version file is empty"); 20 }

Source: migration.cpp

save_current_version() 以截断方式覆写单行版本号,并显式检查流状态:

cpp
1try { 2 std::ofstream file(path); 3 if (!file) { 4 return std::unexpected("Failed to open version file for writing: " + path.string()); 5 } 6 7 file << version; 8 9 if (!file) { 10 return std::unexpected("Failed to write version to file"); 11 } 12 Logger().info("Version saved: {}", version); 13 return {}; 14} catch (const std::exception& e) { 15 return std::unexpected("Error saving version file: " + std::string(e.what())); 16}

Source: migration.cpp

4 段版本号比较算法

解析时对非法/溢出段一律降级为 0,再补齐到 4 段,最后逐段比较——保证任意长度输入都能安全比较:

cpp
1// 简单的版本号比较(支持 x.y.z.w 格式,4段版本号) 2auto compare_versions(const std::string& v1, const std::string& v2) -> int { 3 auto parse_version = [](const std::string& v) -> std::vector<int> { 4 std::vector<int> parts; 5 std::istringstream ss(v); 6 std::string part; 7 8 while (std::getline(ss, part, '.')) { 9 try { 10 parts.push_back(std::stoi(part)); 11 } catch (const std::invalid_argument&) { 12 parts.push_back(0); 13 } catch (const std::out_of_range&) { 14 parts.push_back(0); 15 } 16 } 17 18 // 补齐到4位(与应用版本字符串格式一致) 19 while (parts.size() < 4) { 20 parts.push_back(0); 21 } 22 return parts; 23 }; 24 25 auto parts1 = parse_version(v1); 26 auto parts2 = parse_version(v2); 27 28 for (size_t i = 0; i < 4; ++i) { 29 if (parts1[i] < parts2[i]) return -1; 30 if (parts1[i] > parts2[i]) return 1; 31 } 32 return 0; 33}

Source: migration.cpp

迁移主流程(筛选 + 排序 + 执行)

核心区间筛选与排序逻辑:

cpp
1const auto& all_migrations = scripts::get_all_migrations(); 2std::vector<const scripts::MigrationScript*> scripts_to_run; 3 4for (const auto& script : all_migrations) { 5 if (is_fresh_install && !script.run_on_fresh_install) { 6 continue; 7 } 8 9 // 选择版本号在 (last_version, current_version] 区间的脚本 10 if (compare_versions(script.target_version, last_version) > 0 && 11 compare_versions(script.target_version, current_version) <= 0) { 12 scripts_to_run.push_back(&script); 13 } 14} 15 16// 按版本号排序(确保按顺序执行) 17std::ranges::sort(scripts_to_run, [](const auto* a, const auto* b) { 18 return compare_versions(a->target_version, b->target_version) < 0; 19});

Source: migration.cpp

执行循环:失败即中止、不回写版本号:

cpp
1for (const auto* script : scripts_to_run) { 2 Logger().info("--- Executing migration to {} ---", script->target_version); 3 Logger().info("Description: {}", script->description); 4 5 auto result = script->migration_fn(app_state); 6 7 if (!result) { 8 Logger().error("Migration to {} failed: {}", script->target_version, result.error()); 9 Logger().error("=== Migration Failed ==="); 10 return false; 11 } 12 Logger().info("Migration to {} completed successfully", script->target_version); 13}

Source: migration.cpp

API Reference

以下签名全部来自 migration.hpp,命名空间为 core::migration。所有可失败函数统一返回 std::expected<T, std::string>,错误以人类可读字符串承载,无自定义异常类型。

get_last_version() -> std::expected<std::string, std::string>

读取版本文件第一行,返回上次运行的版本号。

  • 参数:无
  • 返回:成功时为版本字符串;文件不存在时返回 "0.0.0.0"(非错误);路径解析失败 / 打开失败 / 文件为空 / 读取抛异常时返回 std::unexpected
  • 副作用:记录 Last version: {...} 或 Version file not found... 日志

save_current_version(const std::string& version) -> std::expected<void, std::string>

将版本号单行覆写到 app_version.txt(路径由 utils::path::GetAppDataFilePath 解析)。

  • 参数:version (const std::string&) —— 要写入的版本字符串
  • 返回:成功为空值;打开失败 / 写入失败 / 抛异常时返回 std::unexpected
  • 副作用:截断覆写整个版本文件,并记录 Version saved: {...} 日志

compare_versions(const std::string& v1, const std::string& v2) -> int

按 4 段整数逐段比较版本号。

  • 参数:v1、v2 —— 形如 x.y.z.w 的版本字符串(段数不足自动补 0,非法段按 0 处理)
  • 返回:-1 / 0 / 1,分别表示 v1 < v2 / v1 == v2 / v1 > v2
  • 异常:无(内部捕获 std::invalid_argument 与 std::out_of_range 并降级为 0)

run_migration_if_needed(core::AppState& app_state) -> bool

迁移引擎唯一入口,在应用启动阶段调用。

  • 参数:app_state (core::AppState&) —— 应用状态引用,透传给每个迁移函数
  • 返回:true 表示迁移完成或无需迁移;false 表示版本读取失败、某脚本执行失败或版本回写失败
  • 重要行为:返回 false 时版本文件不会被更新,下次启动将重试同一批脚本

scripts::get_all_migrations() -> (const&) std::vector<MigrationScript>

在 core/migration/scripts/scripts.hpp 中声明(本次未读取该文件实现,此处基于调用点推断结构)。从 migration.cpp 的使用可确认 MigrationScript 至少包含:

成员类型(推断)用途
target_version字符串脚本适用的目标版本,用于区间筛选与排序
description字符串人类可读描述,取自 SQL 文件注释,用于日志
run_on_fresh_install布尔全新安装时是否仍需执行
migration_fn可调用对象,签名形如 (core::AppState&) -> std::expected<.., std::string>迁移主体

说明:MigrationScript 的完整定义位于生成链路下游,本页未读取该头文件实现,字段语义以上述调用点证据为限。

配置与命令

项值说明
迁移源目录src/migrations/存放带版本注释的 .sql 文件,唯一事实来源
生成输出目录src/core/migration/generated/由 ["core", "migration", "generated"] 拼接(generate-migrations.js:29-32)
生成命令node scripts/generate-migrations.js修改 src/migrations/*.sql 后必须重新执行
模块名规则V + 3 位补零版本号例:版本 1 → V001
版本文件app_version.txt位于 AppData,由 GetAppDataFilePath 解析
版本文件格式单行 x.y.z.w4 段整数,缺失段按 0 处理

Failure Modes, Edge Cases & Concurrency

  • 版本文件缺失 ≠ 错误:被解释为全新安装(0.0.0.0),并据此跳过 run_on_fresh_install == false 的历史脚本。这是全新库初始化与增量补丁共用同一注册表的关键机制。
  • 版本文件为空:get_last_version 显式返回错误(migration.cpp:40-42),run_migration_if_needed 据此 return false 且不执行任何迁移。
  • 降级运行:last_version > current_version 时区间为空,走"空集兜底"分支——只警告、不失败、仍保存当前版本号(migration.cpp:158-170)。副作用是回退后再升级会跳过中间版本的迁移,因为版本文件已被改写为较低版本。
  • 迁移失败的可重试性:失败中止后不回写版本号,下次启动重试同一批脚本。这要求脚本本身幂等(例如使用 IF NOT EXISTS 之类的 SQL 写法),引擎层并未提供事务回滚——单个脚本内部是否原子取决于生成的 SQL 与数据库层执行方式(本页未在源码中验证到事务包裹逻辑)。
  • 顺序保证:跨版本升级依赖 std::ranges::sort 按版本升序执行;若两个脚本 target_version 完全相同,compare_versions 返回 0,排序为非稳定语义下顺序不定——应避免生成重复 target_version 的迁移。
  • 并发:迁移仅在启动期由单一调用方执行(run_migration_if_needed 是同步、无锁的顺序流程),源码中未见互斥或跨进程锁。若两个进程实例同时启动并竞争写 app_version.txt,可能产生覆盖竞争——当前实现未对此设防。
  • 错误传递风格:全程 std::expected<.., std::string> + Logger() 日志,无异常外抛;版本比较函数内部吞掉解析异常(按 0 处理),保证比较永不抛出。

Performance & Operational Notes

  • 常驻开销极低:版本相同的正常启动只做一次文件读取 + 一次 4 段整数比较(migration.cpp:132-135),不触碰数据库、不遍历脚本表。
  • 日志可观测性:全程有结构化日志标记迁移边界——=== Migration Check Started ===、--- Executing migration to {} ---、=== All Migrations Completed Successfully === / === Migration Failed ===,便于在日志中检索迁移事件。
  • 构建流水线依赖:generate-migrations.js 是手动触发的开发期步骤,忘跑会导致生成的头文件与 SQL 源不同步——项目在 AGENTS.md 中将其列为"源变更后必须重跑"的命令之一。
  • 数据库层的配套约定:SQLite 通过 SQLiteCpp 访问,采用线程局部连接与 DataMapper(ORM 风格行映射),见 AGENTS.md。迁移脚本最终作用于该 SQLite 实例;连接管理与行映射实现细节超出本页范围。

Extension Points

  • 新增迁移:在 src/migrations/ 下新增带版本注释的 .sql 文件 → 运行 node scripts/generate-migrations.js → 重新编译。无需改动 migration.cpp 任何代码——注册表由生成产物自动聚合。
  • 生成器自身:generateCppHeader(migrationFile, version, sqlStatements)(generate-migrations.js:144)是唯一的模板函数,调整生成产物的结构(如新增字段)只需修改它。
  • 迁移函数签名:migration_fn(app_state) 以 AppState& 为唯一入参,未来需要在迁移中访问更多上下文时,应通过扩展 AppState 而非修改迁移引擎接口。

Tests

本次源码探索预算内未读取到针对 core::migration 的测试文件,测试覆盖情况无法从已收集证据中确认,故不在此声称任何测试行为。