mcpField
R
run-migration

run-migration

github.com/zakariaf数据与数据库63142 次安装5 周前更新
代码仓库

关于 run-migration

这项技能为离线优先应用中的 Drift/SQLite 提供仅向前的手动数据库迁移流程。它规定迁移前快照、只追加迁移步骤和侧重数据内容的测试,以保护升级过程中的本地数据。

主要功能

  1. 01迁移前创建数据库快照
  2. 02只追加的向前迁移
  3. 03提交版本化架构快照
  4. 04覆盖所有升级路径的测试
  5. 05数据、完整性与恢复检查

适用场景

  1. 01安全添加 Drift 表或字段
  2. 02测试跨越多个应用版本的升级
  3. 03验证数据保留与快照恢复

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/zakariaf/flutter-skills --skill run-migration

安装后在 Claude Code 中输入/run-migration确认已加载。

详细介绍

运行迁移(仅向前的 Drift 架构迁移)

按照以下严格的有序流程,将架构变更应用到本地 Drift/SQLite 数据库。在离线优先的应用中,没有服务端,也没有重新同步途径,因此设备上的数据库是唯一事实来源:迁移若删除或损坏某一列,就会永久丢失手动录入且不存在于其他任何地方的记录;而且由于没有遥测数据,用户不会反馈问题——只会卸载应用。这是一个手动、限制严格、由人工执行的工作流。请按顺序执行各步骤;不要自行变通、调整顺序或跳过步骤。

不可妥协的规则

  1. 首先创建迁移前快照——必须在打开数据库之前。 在打开 AppDatabase 的组合边界处执行,绝不要在 onUpgrade 内执行:对任何活动句柄运行 PRAGMA wal_checkpoint(TRUNCATE),将 -wal 中的内容合并到主文件,然后在没有任何对象持有文件打开句柄时,复制数据库文件以及它的 -wal/-shm 附属文件。打开数据库就会触发迁移。绝不要在活动连接存在时复制或覆盖数据。快照是 SQLite 唯一的“回滚”方式;任何失败时都要恢复快照。
  2. 仅向前。绝不要编写降级迁移。 SQLite 没有真正的降级迁移,你绝不能假装有。“回滚”意味着恢复快照,仅此而已。
  3. 步骤只能追加——绝不要编辑已发布的步骤。 编辑已发布的步骤会损坏所有跳过版本的用户数据。要修复之前某个步骤中的错误,请添加一个新步骤并递增 schemaVersion——绝不要回头修改历史步骤。
  4. 每次发布架构变更时,schemaVersion 必须恰好递增 1,并添加对应的 fromNToM 分支。架构变更却没有递增版本号,意味着用户设备上根本不会运行迁移——必须由 CI 门禁捕获这一问题。
  5. 提交带版本号的架构快照。 运行 drift_dev make-migrations(或 schema dump),提交导出到 drift_schemas/ 下的快照,并提交 schema_versions.dart。这样变更差异便可供审查,测试套件也会将实时架构与快照进行比对。
  6. 打开数据库的操作必须完全在任何连接之外,由 try/restore 包裹。 如果打开数据库时抛出异常,就表示迁移失败,且该连接已不可用:关闭它,恢复打开数据库之前创建的快照文件,然后重新抛出异常。所有文件操作都必须在没有任何对象打开数据库时进行——绝不要在 onUpgrade 内覆盖活动文件。
  7. 架构检查通过是必要条件,但绝非充分条件。 migrateAndValidate 只会比较 CREATE 语句——它不会读取任何行。一个重建表结构完美却没有复制任何行的迁移,也能通过检查、显示绿色。内容测试才是交付物,而不是架构测试。
  8. 只有完整测试套件全部通过,才能合并迁移——涵盖每一条 from → to 路径(包括跨多个版本的跳转)、为每个新版本对编写内容测试、PRAGMA integrity_check == ok、PRAGMA foreign_key_check 结果为空,以及强制在迁移中途抛出异常并恢复快照的测试。
  9. 任何新列中都只能存储规范值(金额使用与 ISO-4217 指数对应的最小单位整数,数量使用 SI 整数,即时时间使用 UTC Epoch 毫秒,日历日期使用本地序列日整数)。绝不要添加浮点金额列、展示字符串列或本地化数字列。

有序工作流

从拥有数据库的包中运行命令(单包应用:应用根目录;多包应用请参阅末尾的说明)。

  1. 根据数据层约定设计变更(参见 persistence-drift)。编辑表类。
  2. 在数据库类中将 schemaVersion 加 1。
  3. 生成快照和步骤存根,并提交生成的快照:
    dart run drift_dev make-migrations
    
    对于尚未确认 make-migrations 可正常工作的固定版本,请改用以下三个显式命令(参见下方“快照命令”)。
  4. 编写仅追加的前向步骤——新增一个 fromNToM 分支。绝不修改现有分支。只创建此版本新增的对象;绝不引用在后续版本中新增的列。
  5. 重新生成代码,使生成的数据库包含新版本(调用 run-codegen skill):
    dart run build_runner build --delete-conflicting-outputs
    
  6. 运行迁移测试套件(参见“验证”)。所有路径、内容测试、两个 PRAGMA 以及强制抛错后的恢复都必须通过。
  7. 分析、格式化并提交表变更、递增后的 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

评论

支持 Markdown