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 的迁移框架,而是自建了一套极轻量的机制,设计目标可以概括为:
- 迁移即纯 SQL:开发者只需在
src/migrations/下维护带版本注释的.sql文件,不用手写 C++ 迁移代码。 - 编译期内嵌:通过
scripts/generate-migrations.js把 SQL 编译成 C++ 头文件,随二进制分发,运行期不依赖任何外部 SQL 资源文件。 - 版本区间执行:只执行
target_version落在(上一次版本, 当前版本]的脚本,天然支持跨多版本升级。 - 区分全新安装:首次启动(版本文件不存在,等价于
0.0.0.0)时,可跳过标记为"不需要在全新安装时执行"的历史脚本,避免对新建库重复执行早期补丁。
关键概念
| 概念 | 位置 | 说明 |
|---|---|---|
| 版本文件 | app_version.txt(AppData 目录) | 单行文本,记录上次成功运行的 4 段版本号 |
| 4 段版本号 | x.y.z.w | compare_versions 按 4 段整数逐段比较,不足补 0 |
| 全新安装 | last_version == "0.0.0.0" | 版本文件缺失时直接返回 0.0.0.0,不视为错误 |
MigrationScript | scripts::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
架构分层解读:
- 开发期与运行期彻底解耦。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:启动时的迁移决策与执行
逐步解读(对应 migration.cpp:116-200):
- 读取当前版本:来自
core::version::get_app_version()(migration.cpp:119)。 - 读取上次版本:
get_last_version()打开版本文件取第一行(migration.cpp:32-38)。文件不存在时返回"0.0.0.0"并记录Version file not found, assuming first launch(migration.cpp:26-29)——这是全新安装的唯一判定依据,文件缺失被设计为正常路径而非错误。 - 版本相同则直接返回
true(migration.cpp:132-135),零开销跳过。 - 脚本筛选:两个条件的合取(
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]。
- 全新安装时跳过
- 升序排序:
std::ranges::sort(migration.cpp:154-156)保证多版本跨级升级时脚本按引入顺序执行——这是 schema 演进正确性的前提。 - 空集兜底:找不到脚本时只
warn不fail,仍然保存当前版本号(migration.cpp:158-170)。设计意图:版本回退(降级运行)或脚本缺失不应阻断启动,且要让版本文件跟上实际运行的程序版本。 - 执行失败即中止:任一脚本失败立即
return false且不回写版本号(migration.cpp:181-185)——下次启动会再次尝试同一批脚本,实现"失败可重试"的幂等策略基础。 - 成功后回写版本(
migration.cpp:191-196)。
Usage Examples
生成器入口与注释头(代码生成器)
generate-migrations.js 的自述注释完整定义了输入、输出与切分规则:
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 位数字作为模块后缀:
function generateCppHeader(migrationFile, version, sqlStatements) {
const moduleSuffix = `V${String(version).padStart(3, "0")}`;Source: generate-migrations.js
生成头文件带有明确的禁改标记,指向其源 SQL 文件,形成"SQL 是唯一事实来源"的约定:
// DO NOT EDIT - This file is generated from
// src/migrations/${migrationFile}Source: generate-migrations.js
目录解析部分确认了输入/输出路径(注意 generated 目录由 core → migration → generated 三段拼接而成):
const migrationsDir = path.join(projectRoot, "src", "migrations");Source: generate-migrations.js
版本文件读写(迁移引擎)
get_last_version() 展示了"文件缺失 = 全新安装"的容错路径:
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() 以截断方式覆写单行版本号,并显式检查流状态:
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 段,最后逐段比较——保证任意长度输入都能安全比较:
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
迁移主流程(筛选 + 排序 + 执行)
核心区间筛选与排序逻辑:
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
执行循环:失败即中止、不回写版本号:
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.w | 4 段整数,缺失段按 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 的测试文件,测试覆盖情况无法从已收集证据中确认,故不在此声称任何测试行为。
Related Links
- 迁移引擎头文件:migration.hpp
- 迁移引擎实现:migration.cpp
- 迁移脚本生成器:generate-migrations.js
- 项目架构说明(数据库与迁移约定):AGENTS.md