关于 run-migration
这项技能为离线优先应用中的 Drift/SQLite 提供仅向前的手动数据库迁移流程。它规定迁移前快照、只追加迁移步骤和侧重数据内容的测试,以保护升级过程中的本地数据。
主要功能
- 01迁移前创建数据库快照
- 02只追加的向前迁移
- 03提交版本化架构快照
- 04覆盖所有升级路径的测试
- 05数据、完整性与恢复检查
适用场景
- 01安全添加 Drift 表或字段
- 02测试跨越多个应用版本的升级
- 03验证数据保留与快照恢复
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill run-migration安装后在 Claude Code 中输入/run-migration确认已加载。
详细介绍
运行迁移(仅向前的 Drift 架构迁移)
按照以下严格的有序流程,将架构变更应用到本地 Drift/SQLite 数据库。在离线优先的应用中,没有服务端,也没有重新同步途径,因此设备上的数据库是唯一事实来源:迁移若删除或损坏某一列,就会永久丢失手动录入且不存在于其他任何地方的记录;而且由于没有遥测数据,用户不会反馈问题——只会卸载应用。这是一个手动、限制严格、由人工执行的工作流。请按顺序执行各步骤;不要自行变通、调整顺序或跳过步骤。
不可妥协的规则
- 首先创建迁移前快照——必须在打开数据库之前。
在打开
AppDatabase的组合边界处执行,绝不要在onUpgrade内执行:对任何活动句柄运行PRAGMA wal_checkpoint(TRUNCATE),将-wal中的内容合并到主文件,然后在没有任何对象持有文件打开句柄时,复制数据库文件以及它的-wal/-shm附属文件。打开数据库就会触发迁移。绝不要在活动连接存在时复制或覆盖数据。快照是 SQLite 唯一的“回滚”方式;任何失败时都要恢复快照。 - 仅向前。绝不要编写降级迁移。 SQLite 没有真正的降级迁移,你绝不能假装有。“回滚”意味着恢复快照,仅此而已。
- 步骤只能追加——绝不要编辑已发布的步骤。 编辑已发布的步骤会损坏所有跳过版本的用户数据。要修复之前某个步骤中的错误,请添加一个新步骤并递增
schemaVersion——绝不要回头修改历史步骤。 - 每次发布架构变更时,
schemaVersion必须恰好递增 1,并添加对应的fromNToM分支。架构变更却没有递增版本号,意味着用户设备上根本不会运行迁移——必须由 CI 门禁捕获这一问题。 - 提交带版本号的架构快照。 运行
drift_dev make-migrations(或schema dump),提交导出到drift_schemas/下的快照,并提交schema_versions.dart。这样变更差异便可供审查,测试套件也会将实时架构与快照进行比对。 - 打开数据库的操作必须完全在任何连接之外,由 try/restore 包裹。 如果打开数据库时抛出异常,就表示迁移失败,且该连接已不可用:关闭它,恢复打开数据库之前创建的快照文件,然后重新抛出异常。所有文件操作都必须在没有任何对象打开数据库时进行——绝不要在
onUpgrade内覆盖活动文件。 - 架构检查通过是必要条件,但绝非充分条件。
migrateAndValidate只会比较CREATE语句——它不会读取任何行。一个重建表结构完美却没有复制任何行的迁移,也能通过检查、显示绿色。内容测试才是交付物,而不是架构测试。 - 只有完整测试套件全部通过,才能合并迁移——涵盖每一条
from → to路径(包括跨多个版本的跳转)、为每个新版本对编写内容测试、PRAGMA integrity_check == ok、PRAGMA foreign_key_check结果为空,以及强制在迁移中途抛出异常并恢复快照的测试。 - 任何新列中都只能存储规范值(金额使用与 ISO-4217 指数对应的最小单位整数,数量使用 SI 整数,即时时间使用 UTC Epoch 毫秒,日历日期使用本地序列日整数)。绝不要添加浮点金额列、展示字符串列或本地化数字列。
有序工作流
从拥有数据库的包中运行命令(单包应用:应用根目录;多包应用请参阅末尾的说明)。
- 根据数据层约定设计变更(参见
persistence-drift)。编辑表类。 - 在数据库类中将
schemaVersion加 1。 - 生成快照和步骤存根,并提交生成的快照:
对于尚未确认dart run drift_dev make-migrationsmake-migrations可正常工作的固定版本,请改用以下三个显式命令(参见下方“快照命令”)。 - 编写仅追加的前向步骤——新增一个
fromNToM分支。绝不修改现有分支。只创建此版本新增的对象;绝不引用在后续版本中新增的列。 - 重新生成代码,使生成的数据库包含新版本(调用
run-codegenskill):dart run build_runner build --delete-conflicting-outputs - 运行迁移测试套件(参见“验证”)。所有路径、内容测试、两个 PRAGMA 以及强制抛错后的恢复都必须通过。
- 分析、格式化并提交表变更、递增后的
schemaVersion、新步骤、已提交的快照和测试,并将它们作为一次变更一并提交。
迁移保护机制
快照/恢复位于组合边界,完全在任何已打开的连接之外;onUpgrade 只运行步骤并检查外键。保持此确切结构。
// ── composition boundary (app_database_opener.dart) ─────────────────────────
// Snapshot BEFORE the open, restore AFTER a failed open — no connection is ever
// live while the bytes are copied. Opening is what triggers onUpgrade.
Future<AppDatabase> openMigratedDatabase(File dbFile) async {
// 1. Checkpoint any prior handle, then copy dbFile + its -wal/-shm sidecars.
final snapshot = await _snapshotDbFiles(dbFile);
final db = AppDatabase(NativeDatabase(dbFile));
try {
// 2. Force the migration to run NOW, while the file fallback still stands.
await db.customStatement('PRAGMA user_version;');
return db;
} catch (_) {
// 3. The failed connection is dead — close it, THEN restore with nothing open.
await db.close();
await _restoreDbFiles(snapshot);
rethrow;
}
}
// ── app_database.dart ───────────────────────────────────────────────────────
@override
int get schemaVersion => 3; // bump by exactly ONE per shipped schema change
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
// FKs OFF here — BEFORE the migration's own transaction opens, because
// the pragma is ignored once a transaction is running.
await customStatement('PRAGMA foreign_keys = OFF;');
// Forward-only, append-only stepwise migration in one transaction.
await transaction(() => _steps(m, from, to));
// Prove the body left no dangling references — in production, not tests.
final orphans = await customSelect('PRAGMA foreign_key_check').get();
if (orphans.isNotEmpty) {
throw StateError('foreign_key_check found ${orphans.length} orphans');
}
},
// Pragmas are per-connection, NOT persisted — re-assert on every open.
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON;');
},
);
final _steps = stepByStep(
from1To2: (m, schema) async { /* shipped — DO NOT EDIT */ },
from2To3: (m, schema) async {
// Create EXACTLY the objects this version adds.
await m.addColumn(schema.notes, schema.notes.archivedAtUtc);
},
);
快照命令(显式形式)
不使用 make-migrations 时,每次递增 schemaVersion 都要重新运行以下三个命令,并提交结果:
# 1. Export the schema for the CURRENT schemaVersion.
dart run drift_dev schema dump lib/data/app_database.dart drift_schemas/
# 2. Era-correct classes. --data-classes --companions is LOAD-BEARING: it emits
# DatabaseAtV1 / DatabaseAtV2 with era-correct data classes and companions,
# the only mechanism that lets a test write rows in the v1 world and read
# them back in the v2 world. Without it only shape tests are possible.
dart run drift_dev schema generate --data-classes --companions \
drift_schemas/ test/drift/generated/
# 3. Step-by-step helpers — each callback gets a schema frozen at its version.
dart run drift_dev schema steps drift_schemas/ lib/data/schema_versions.dart
验证(阻断项——提交前必须通过)
测试套件必须证明以下所有内容。
每一条 from→to 路径,包括跳过的版本。 忽略更新的用户会直接从 v1 → v3 升级——这是一条无人运行过的代码路径。嵌套循环会永久覆盖每一对版本,因此新增版本时不会漏掉跳过版本的路径:
group('schema shape', () {
for (var from = 1; from < kLatestSchemaVersion; from++) {
for (var to = from + 1; to <= kLatestSchemaVersion; to++) {
test('v$from -> v$to', () async {
final connection = await verifier.startAt(from);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, to); // shape only — necessary, not sufficient
await db.close();
});
}
}
});
内容测试——最重要的测试。 在 v(n) 使用符合该时期的类写入行,运行实际迁移,在 v(n+1) 读回,并断言这些行仍然存在、内容仍然正确且位置未变。使用刁钻的测试数据——撇号、非 ASCII 字符、长破折号、表情符号、引号、反斜杠、纯空白字符——尤其要放在 columnTransformer 或手写的 INSERT ... SELECT 容易破坏值的位置。'test1' 无论如何都能保留,因此无法证明任何问题。为每个 expect 添加 reason:——没有崩溃报告时,失败消息就是整个调试过程。
test('v2 -> v3 preserves every note, byte for byte', () async {
final schema = await verifier.schemaAt(2);
// Write real data with v2-era classes. newConnection() over the SAME bytes is
// what lets different-era database objects see one database.
final old = v2.DatabaseAtV2(schema.newConnection());
await old.into(old.notes).insert(
v2.NotesCompanion.insert(title: 'Blank-ish', body: const Value(' ')),
);
await old.close();
// Run the REAL migration, then prove integrity — FKs are off during migration
// and SQLite reports a dangling reference to nobody.
final db = AppDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, 3);
expect(await db.customSelect('PRAGMA integrity_check').get(),
[predicate((QueryRow r) => r.data.values.first == 'ok')]);
expect(await db.customSelect('PRAGMA foreign_key_check').get(), isEmpty,
reason: 'migration left dangling references — a lost record');
await db.close();
// Read back with v3-era classes and assert CONTENT, not just shape.
final now = v3.DatabaseAtV3(schema.newConnection());
final rows = await now.select(now.notes).get();
expect(rows, hasLength(1), reason: 'a row disappeared during migration');
expect(rows.single.body, ' ', reason: 'whitespace-only body must survive intact');
await now.close();
});
强制触发迁移中途抛错时会恢复快照。 注入一个会在执行到一半时抛错的步骤,通过边界打开器打开数据库以触发迁移,断言打开操作会重新抛错,且打开前的快照已恢复——之前的架构和行逐字节保持一致。恢复路径是最后一道防线,也是生产环境中直到真正需要时才会被触发的防线。
不能仅凭分析器通过就将迁移标记为完成。 只有内容测试、两个 PRAGMA、覆盖所有路径的循环和快照恢复测试全部通过后,才算安全。
会静默失败的迁移机制
- 打开事务后,
PRAGMA foreign_keys会被忽略——在onUpgrade开头、包装步骤的transaction()之前关闭外键,并在beforeOpen中无条件重新启用(该 pragma 按连接生效,不会持久化到文件中)。 - 在迁移主体执行后运行
PRAGMA foreign_key_check,不能只在测试中运行。 flutter test在纯 Dart VM 中运行,sqlite3_flutter_libs在此环境下不起作用;macOS 会回退到系统的libsqlite3(存在实际的版本不匹配风险),Linux 则必须在运行前安装libsqlite3-dev。
反模式
- 编辑已发布的步骤 — 会改写现有安装版本的历史;跳过版本的用户会用编辑后的步骤处理旧数据,导致数据丢失。
- 编写回滚迁移 — SQLite 不支持回滚迁移;唯一的回滚方式是恢复快照。
- 更改 schema 却不递增
schemaVersion— 用户设备上不会运行迁移;schema 和代码会悄然不一致。 - 仅凭
migrateAndValidate就标记完成 — 它只比较结构,不读取任何行;即使迁移没有复制任何数据,也会通过验证。 - 仅用
'test1'测试数据验证INSERT ... SELECT/columnTransformer— 数据损坏往往出现在撇号、表情符号和反斜杠中。 - 在较早的步骤中对尚未添加的列执行
select/update— 写入查询只能针对该步骤版本所固定的 schema。 - 假设 pragma 会保存在文件中 —
foreign_keys、synchronous和任何加密密钥都按连接生效;每次打开时都要重新设置。 - 让发布构建仍可触发
eraseDatabaseOnSchemaChange— 这只是 DEBUG 专用的便利功能;若随版本发布,每次版本升级都会清除真实用户数据。 - 把迁移测试写成一个大测试,而不是遍历所有版本对 — 下次升级时,最容易被遗忘的正是跳过版本的路径。
完成标准
-
schemaVersion恰好递增 1;追加对应的fromNToM步骤;不得编辑任何已发布的步骤;不得添加回滚迁移。 - 重新运行
make-migrations(或三个明确的schema命令);提交的快照和schema_versions.dart必须包含在同一变更中。 - 在打开数据库前创建迁移前快照;打开操作须用 try/恢复并重新抛出错误的逻辑包裹,且所有文件操作都在连接处于活动状态之外进行。
- 使用
--delete-conflicting-outputs重新生成代码。 - 结构循环覆盖直到
kLatestSchemaVersion的所有版本对。 - 为新增版本对添加内容测试,使用有破坏性的测试数据,为每个
expect添加reason:,并按键(而非列表索引)断言。 - 迁移后
PRAGMA integrity_check == ok,且PRAGMA foreign_key_check结果为空;在迁移事务前禁用外键,并在beforeOpen中重新启用。 - 强制触发迁移中途异常时,应恢复快照,且原有 schema 和数据行保持完整。
-
eraseDatabaseOnSchemaChange仅限 DEBUG 使用,发布构建无法触发。 - Analyzer 和 formatter 检查通过;表变更、版本递增、步骤、快照和测试一并提交。
多包时(工作区)
如果 Drift 限定在 Dart pub 工作区中的某个数据包内,则从该数据包运行所有命令(make-migrations 可从工作区根目录运行),将 drift_schemas/ 和 schema_versions.dart 保存在该数据包中,并在该数据包中运行迁移测试套件。对于单包应用,所有内容都位于应用根目录——这些流程并不要求使用工作区。
相关技能
- 查看
persistence-drift,了解 schema 变更必须遵守的数据层约定(STRICT 表、schema 级 CHECK/FK、WAL/pragmas、DAO 事务)。 - 查看
run-codegen,了解第 5 步中运行的确定性build_runner流程。 - 查看
error-handling-typed-results,了解此工作流所补充的数据绝不丢失保障(事务、自动保存草稿、软删除/撤销)。 - 查看
testing-strategy,了解内容测试和结构测试遵循的真实内存数据库测试规范。 - 查看
ci-pipeline-and-gates,了解 schema 新鲜度门禁;如果 schema 已更改但未提交更新的快照,该门禁会使构建失败。
References
- Drift — Migrations: https://drift.simonbinder.eu/Migrations/
- Drift — Migration test API (
SchemaVerifier,make-migrations): https://drift.simonbinder.eu/Migrations/tests/ - Drift —
stepByStep: https://drift.simonbinder.eu/Migrations/step_by_step/ - SQLite —
PRAGMA integrity_check/foreign_key_check: https://sqlite.org/pragma.html - SQLite — Write-Ahead Logging: https://sqlite.org/wal.html