ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Actual 数据库迁移指南:从迁移命名规范到执行引擎的完整实践

2026/9/12 23:46:40 拓冰建站 浏览量
Actual 数据库迁移指南:从迁移命名规范到执行引擎的完整实践 Actual 数据库迁移指南从迁移命名规范到执行引擎的完整实践【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual本指南以 Actual本地优先的个人理财应用的官方数据库迁移文档为骨架结合仓库中真实的迁移文件、执行引擎与测试用例系统讲解在 Actual 中为新增功能添加数据库迁移时必须遵守的规范、注意事项与底层执行原理。读完本文你将掌握迁移文件的命名与编写规则、SQL 与 JavaScript 两种迁移形态、AQL Schema 同步要求以及迁移在启动时如何被校验与执行。为什么需要数据库迁移Actual 是一个本地优先local-first的桌面与移动端理财应用用户数据存放在本地 SQLite 数据库中。当应用通过版本更新引入新功能时往往需要改变数据库结构例如新增一张表、给已有表添加一列或者创建索引来优化查询性能。这些结构变更不能靠用户在界面手动完成而是通过**数据库迁移DB Migration**在应用启动或升级时自动执行。官方文档 DB Migrations Guide 指出当你为一个需要数据库变更的功能写代码时必须把以下四点纳入考量数据库迁移需要发布一个新的 API 版本因为迁移同样需要在 API 服务端应用AQL Schema 文件很可能需要更新以匹配表结构的变化迁移文件必须放在loot-core/migrations目录下并遵循严格的命名约定应当尽量避免删除列和表并极其审慎地设计迁移内容。下面逐条展开并结合仓库源码说明背后的原因与实现。迁移文件的目录与命名规范存放位置所有迁移文件必须放在 packages/loot-core/migrations 目录下。截至当前仓库该目录已积累了 50 余个迁移文件时间跨度从 2019 年1548957970627_remove-db-version.sql一直到近期1787013118115_add_account_groups.sql完整记录了 Actual 数据库结构的演进历史。命名约定TIMESTAMP_name.sql命名规范为TIMESTAMP_name.sql例如官方文档给出的示例1694438752000_add_goal_targets.sql其中前缀是一个 13 位的毫秒级时间戳实际就是创建迁移时的Date.now()。从源码看迁移引擎正是靠这个前缀数字来识别迁移 ID 并排序的。在 migrations.ts 中function getMigrationId(name: string): number { return parseInt(name.match(/^(\d)/)[0]); }而 getMigrationList 在扫描迁移目录时只保留.sql或.js结尾的文件并按迁移 ID 升序排序保证迁移严格按照时间顺序执行const files await fs.listDir(migrationsDir); return files .filter(name name.match(/(\.sql|\.js)$/)) .sort((m1, m2) { const id1 getMigrationId(m1); const id2 getMigrationId(m2); ... });仓库中真实的迁移文件也印证了这一规范例如1707267033000_reports.sql新增报表功能1720310586000_link_transfer_schedules.sql关联转账与排期1749799110000_add_tags.sql新增标签功能1783004650757_schedule_sort_order.sql排期排序使用 CLI 生成迁移模板手工拼时间戳容易出错仓库提供了现成的 CLI 工具 cli.ts。create命令会自动以当前毫秒时间戳为前缀生成一个带有事务模板的.sql文件function create(migrationName) { const ts Date.now(); const up path.resolve(migrationsDir, ts _ migrationName .sql); fs.writeFileSync(up, BEGIN TRANSACTION;\n\nCOMMIT;, utf8); }生成的骨架文件内容为BEGIN TRANSACTION; COMMIT;你只需在BEGIN TRANSACTION;与COMMIT;之间填写实际的 SQL 变更即可。一个典型的迁移文件例如 1694438752000_add_goal_targets.sqlBEGIN TRANSACTION; ALTER TABLE zero_budgets ADD column goal INTEGER DEFAULT null; ALTER TABLE reflect_budgets ADD column goal INTEGER DEFAULT null; ALTER TABLE categories ADD column goal_def TEXT DEFAULT null; COMMIT;再如 1780606215001_add_performance_indexes.sql展示了如何通过迁移为高频查询创建索引BEGIN TRANSACTION; CREATE INDEX IF NOT EXISTS idx_transactions_acct_tombstone ON transactions(acct, tombstone); CREATE INDEX IF NOT EXISTS idx_transactions_schedule ON transactions(schedule); COMMIT;可以看到无论加列还是建索引统一使用BEGIN TRANSACTION; ... COMMIT;包裹确保迁移要么整体成功、要么整体回滚这是保证用户数据一致性的基础。SQL 迁移与 JavaScript 迁移两种形态虽然目录下的文件绝大多数是.sql但也有少量.js迁移。原因在 migrations.ts 的注释中写得很清楚JS 迁移必须被打包进应用而不是运行时eval因为eval与浏览器环境的 CSP内容安全策略不兼容。因此这些 JS 迁移被显式 import 到执行引擎中import m1632571489012 from #migrations/1632571489012_remove_cache; import m1722717601000 from #migrations/1722717601000_reports_move_selected_categories; import m1722804019000 from #migrations/1722804019000_create_dashboard_table; import m1723665565000 from #migrations/1723665565000_prefs; import m1765518577215 from #migrations/1765518577215_multiple_dashboards;applyMigration根据文件扩展名分发到不同的执行路径migrations.tsexport async function applyMigration(db, name, migrationsDir): Promisevoid { const code await fs.readFile(fs.join(migrationsDir, name)); if (name.match(/\.js$/)) { await applyJavaScript(db, getMigrationId(name)); } else { await applySql(db, code); } sqlite.runQuery(db, INSERT INTO __migrations__ (id) VALUES (?), [ getMigrationId(name), ]); }注意最后一步每成功执行一个迁移就把它的 ID 写入__migrations__表。这张表是应用判断哪些迁移已经执行过的唯一依据。为什么需要 JS 迁移纯 SQL 能覆盖绝大部分结构变更但当迁移需要读取数据并转换时例如把旧字段的值迁移到新表、重排数据、为既有行生成新 ID就必须借助 JS。以 1765518577215_multiple_dashboards.js 为例它在创建dashboard_pages表后用uuidv4()生成默认仪表盘 ID并把存量 widgets 全部归入该默认仪表盘export default async function runMigration(db) { db.transaction(() { db.execQuery( CREATE TABLE dashboard_pages (id TEXT PRIMARY KEY, name TEXT, tombstone INTEGER DEFAULT 0); ); db.execQuery( ALTER TABLE dashboard ADD COLUMN dashboard_page_id TEXT; ); const defaultDashboardId uuidv4(); db.runQuery(INSERT INTO dashboard_pages (id, name) VALUES (?, ?), [ defaultDashboardId, Main, ]); db.runQuery(UPDATE dashboard SET dashboard_page_id ?, [ defaultDashboardId, ]); }); }JS 迁移通过applyJavaScript注入一个封装好的db接口migrations.ts提供runQuery、execQuery、transaction三个方法外加fs与当前fileId上下文方便迁移脚本读取文件或访问当前预算文件信息const dbInterface { runQuery: (query, params, fetchAll) sqlite.runQuery(db, query, params, fetchAll), execQuery: query sqlite.execQuery(db, query), transaction: func sqlite.transaction(db, func), };迁移的校验、补丁与执行流程启动时的迁移执行迁移的入口是migrate()函数migrations.ts流程如下修补历史坏迁移调用patchBadMigrations处理历史上的异常迁移记录见下文读取已应用 IDgetAppliedMigrations从__migrations__表读出所有已执行迁移的 ID扫描可用迁移getMigrationList列出目录中所有.sql/.js迁移并按时间戳排序校验一致性checkDatabaseValidity对比已应用列表与可用列表计算待执行迁移getPending过滤出尚未应用的迁移逐个执行对每个待执行迁移调用applyMigration。一致性校验防止数据库与代码失步checkDatabaseValiditymigrations.ts做了两道防线如果已应用的迁移数量大于可用迁移数量说明数据库来自更新版本的代码当前应用版本过旧直接抛出out-of-sync-migrations错误如果已应用的迁移 ID 与可用列表对应位置的 ID 不一致说明迁移序列被打乱或缺失同样抛出out-of-sync-migrations。这段校验逻辑在测试 migrations.test.ts 中得到了验证测试向__migrations__插入一个随机的迁移 ID1000随后migrate()就会抛错证明引擎能够识别数据库里有未知迁移的情况。历史坏迁移的修补patchBadMigrationsmigrations.ts是一个值得注意的特例历史上的1685375406832号迁移被证实有问题引擎会在正式迁移前检查__migrations__表若发现该坏迁移 ID就把它删除并替换为正确的1688749527273const badFiltersMigration 1685375406832; const newFiltersMigration 1688749527273; const appliedIds await getAppliedMigrations(db); if (appliedIds.includes(badFiltersMigration)) { sqlite.runQuery(db, DELETE FROM __migrations__ WHERE id ?, [badFiltersMigration]); sqlite.runQuery(db, INSERT INTO __migrations__ (id) VALUES (?), [newFiltersMigration]); }这展示了迁移系统面对历史包袱时的处理思路迁移记录本身也需要可修补能力才能纠正早期发布中的错误。新增迁移的三大纪律回到官方文档以下三条纪律直接决定迁移的质量与系统的长期健康纪律一迁移需要发布新的 API 版本文档明确指出DB Migrations 将要求发布一个新的 API 版本因为迁移也需要在 API 服务端应用。原因不难理解——Actual 的桌面客户端、移动端与自托管 API 服务共享同一套数据格式与同步协议如果客户端升级了数据库结构而 API 端没有同步升级同步过程就会因表结构不一致而失败。因此任何包含数据库迁移的功能改动都必须与 API 版本发布绑定。纪律二AQL Schema 文件需要同步更新文档提醒AQL Schema 文件很可能需要更新以匹配表结构的变化。AQL 是 Actual 内置的查询语言Actual Query Language用于在客户端与服务器间以结构化方式查询预算数据。AQL 的字段定义、表结构描述与 SQL 查询生成逻辑都集中在 packages/loot-core/src/server/aql 目录下其中 schema/index.ts 与 schema/executors.ts 定义了 schema 与执行器配套的 exec.test.ts 与 executors.test.ts 则对查询行为做了完整校验。从源码结构可以推断当迁移给某张表新增了列例如add_tags迁移为交易添加标签字段AQL schema 也必须同步声明新字段否则通过 AQL 发起的查询将无法感知该列导致新功能的数据无法被正常读写。这是数据库结构与查询抽象层必须保持一致的直接体现。纪律三严禁随意删除列与表文档用强烈不建议strongly discouraged的措辞强调不要试图删除列和表。理由是删除会让回滚变得不可能一旦列或表被物理删除旧版本代码读取数据时就会失败用户无法安全降级引入不必要的风险删除操作本身在同步、备份恢复等场景下都可能引发数据损坏正确做法是停止使用只需要在代码层面不再引用这些列/表即可让它们自然沉淀在数据库中。仓库中的迁移也遵循了这一原则——绝大多数迁移都是ALTER TABLE ... ADD COLUMN、CREATE TABLE、CREATE INDEX这类只增不改的操作即使要移除旧行为也多以 tombstone墓碑标记如tombstone INTEGER DEFAULT 0方式软删除而非物理删除。纪律四迁移设计要深思熟虑文档最后建议在添加功能时应提前设想未来可能出现的场景与选项尽量把后续可能用到的扩展点一并设计好从而最小化迁移的数量。这一点在仓库的迁移历史中体现得很明显例如1780327681000_add_tags_hidden.sql在1749799110000_add_tags.sql之后不久就为标签补充了hidden字段1780606215001_add_performance_indexes.sql则在功能上线后为性能补建索引——这些都是先跑通、再迭代的自然结果。设计迁移时若能提前把这类演进纳入考虑就能减少这类紧随其后的修补型迁移。用 CLI 管理迁移状态仓库的迁移 CLIcli.ts除create外还提供三个运维命令可用于调试与检查migrate对指定数据库执行所有待应用的迁移执行完毕后输出已应用的迁移列表若无待执行迁移则提示No pending migrationslist分别列出已应用迁移与待应用迁移便于核对数据库与代码的同步状态reset删除数据库文件并从 init.sql 重新初始化——这是开发环境重建数据库的快捷方式生产环境切勿使用。list命令的实现cli.ts完整复用了引擎的getAppliedMigrations、getMigrationList、getPending三个函数与测试 migrations.test.ts 中的行为一一对应可以作为理解迁移状态机的直观入口。给开发者的完整检查清单综合官方文档与源码实现在 Actual 中为功能添加数据库迁移时建议按以下清单逐项确认文件位置迁移文件放入packages/loot-core/migrations或使用 CLIcreate命令生成骨架命名规范确保文件名是TIMESTAMP_name.sql或TIMESTAMP_name.js前缀为毫秒级时间戳且不能与现有迁移 ID 重复事务包裹SQL 迁移用BEGIN TRANSACTION; ... COMMIT;包裹保证原子性只增不改只用ADD COLUMN、CREATE TABLE、CREATE INDEX等增量操作不删除列或表需要移除时改为代码层面停用或使用 tombstone 软删除API 版本确认迁移会随新的 API 版本一并发布避免客户端与服务端结构失步AQL Schema若改动涉及可查询字段同步更新 packages/loot-core/src/server/aql/schema 下的 schema 定义并补充相应测试数据迁移逻辑若涉及存量数据转换改用 JS 迁移形态并利用注入的db接口runQuery/execQuery/transaction完成测试验证参考 migrations.test.ts 的用例覆盖正常按序迁移检测未知迁移迁移后表结构正确等关键场景。完成以上步骤后你的迁移会在应用升级启动时被迁移引擎自动校验并执行用户数据即可平滑过渡到新版本的数据结构。【免费下载链接】actualA local-first personal finance app项目地址: https://gitcode.com/GitHub_Trending/ac/actual创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考