fix(app): guard force-column migration against downgrade-then-reupgrade

Review hardening for Task 8 (test-server workflow: testers install older
APKs over a newer DB):

- Old APK opening a v2 database hits drift's downgrade path, which leaves
  the force column in place but writes user_version back to 1. Reinstalling
  the new APK then re-runs onUpgrade(1,2) and addColumn crashes with
  "duplicate column name: force", failing the DB open.
- onUpgrade now checks PRAGMA table_info(upload_tasks) and only adds the
  force column when absent; the normal v1 -> v2 path is unchanged.
- Migration test simulates the downgrade-reupgrade cycle on a file-backed
  database (v2 open -> user_version forced back to 1 -> re-upgrade),
  asserting the open succeeds, legacy rows survive, and the column stays
  writable. RED before the guard (SqliteException: duplicate column name).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 17:27:52 +08:00
parent 29f3bde9c2
commit 46f9d93fd3
2 changed files with 105 additions and 40 deletions
@@ -141,7 +141,20 @@ class UploadTaskDb extends _$UploadTaskDb {
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async { onUpgrade: (m, from, to) async {
if (from == 1) { if (from == 1) {
await m.addColumn(uploadTasks, uploadTasks.force); // 降级-重升级防护:旧 APK 覆盖安装打开 v2 库时,drift 降级
// 路径不改表、只把 user_version 写回 1(force 列留存);随后
// 新 APK 重升级再次走到这里——无条件 addColumn 会撞
// 「duplicate column name: force」致库打开失败。先探测列
// 存在性,缺列才补(正常 v1 → v2 路径列必缺省,行为不变)。
final columns = await m.database
.customSelect('PRAGMA table_info(upload_tasks)')
.get();
final forceExists = columns.any(
(row) => row.read<String>('name') == 'force',
);
if (!forceExists) {
await m.addColumn(uploadTasks, uploadTasks.force);
}
} }
}, },
); );
@@ -11,6 +11,8 @@
/// - datetime 文本落库的亚秒精度。 /// - datetime 文本落库的亚秒精度。
library; library;
import 'dart:io';
import 'package:drift/drift.dart' show driftRuntimeOptions; import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart' show NativeDatabase; import 'package:drift/native.dart' show NativeDatabase;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -558,46 +560,52 @@ void main() {
}); });
group('schema 迁移(v1 → v2:force 列,Task 8)', () { group('schema 迁移(v1 → v2:force 列,Task 8)', () {
/// v1 底库种子(建表无 force 列 + legacy 行 + user_version=1)。语句
/// 拆列供内存/文件两种打开方式复用(raw 类型由 setup 上下文推断,
/// 免引 sqlite3 传递依赖)。
const List<String> v1SeedStatements = <String>[
'''CREATE TABLE IF NOT EXISTS "upload_tasks" (
"id" TEXT NOT NULL PRIMARY KEY,
"account_id" TEXT NOT NULL,
"album_id" TEXT NOT NULL,
"category_id" TEXT NULL,
"client_task_id" TEXT NOT NULL,
"source_file_path" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"bytes" INTEGER NOT NULL,
"content_type" TEXT NOT NULL,
"state" TEXT NOT NULL,
"attempt" INTEGER NOT NULL DEFAULT 0,
"next_retry_at" TEXT NULL,
"server_upload_id" TEXT NULL,
"server_photo_id" TEXT NULL,
"last_error" TEXT NULL,
"cleaned_at" TEXT NULL,
"created_at" TEXT NOT NULL,
"updated_at" TEXT NOT NULL
);''',
'''INSERT INTO upload_tasks (
"id", "account_id", "album_id", "client_task_id",
"source_file_path", "filename", "bytes", "content_type",
"state", "created_at", "updated_at"
) VALUES (
'task-legacy', 'alice', 'alb-1', 'ct-legacy',
'/managed/uploads/alb-1/legacy.jpg', 'IMG_LEGACY.jpg', 2048,
'image/jpeg', 'completed',
'2026-09-01T00:00:00.000Z', '2026-09-01T00:01:00.000Z'
);''',
'PRAGMA user_version = 1;',
];
void runSeed(raw) {
for (final statement in v1SeedStatements) {
raw.execute(statement);
}
}
/// v1 底库(无 force 列)+ 一条既有行,user_version=1——升级后打开。 /// v1 底库(无 force 列)+ 一条既有行,user_version=1——升级后打开。
UploadTaskDb legacyV1Db() => UploadTaskDb( UploadTaskDb legacyV1Db() =>
NativeDatabase.memory(setup: (raw) { UploadTaskDb(NativeDatabase.memory(setup: runSeed));
raw.execute('''
CREATE TABLE IF NOT EXISTS "upload_tasks" (
"id" TEXT NOT NULL PRIMARY KEY,
"account_id" TEXT NOT NULL,
"album_id" TEXT NOT NULL,
"category_id" TEXT NULL,
"client_task_id" TEXT NOT NULL,
"source_file_path" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"bytes" INTEGER NOT NULL,
"content_type" TEXT NOT NULL,
"state" TEXT NOT NULL,
"attempt" INTEGER NOT NULL DEFAULT 0,
"next_retry_at" TEXT NULL,
"server_upload_id" TEXT NULL,
"server_photo_id" TEXT NULL,
"last_error" TEXT NULL,
"cleaned_at" TEXT NULL,
"created_at" TEXT NOT NULL,
"updated_at" TEXT NOT NULL
);
''');
raw.execute('''
INSERT INTO upload_tasks (
"id", "account_id", "album_id", "client_task_id",
"source_file_path", "filename", "bytes", "content_type",
"state", "created_at", "updated_at"
) VALUES (
'task-legacy', 'alice', 'alb-1', 'ct-legacy',
'/managed/uploads/alb-1/legacy.jpg', 'IMG_LEGACY.jpg', 2048,
'image/jpeg', 'completed',
'2026-09-01T00:00:00.000Z', '2026-09-01T00:01:00.000Z'
);
''');
raw.execute('PRAGMA user_version = 1;');
}),
);
test('旧库升级:既有行保留、force=false 默认、新列可写', () async { test('旧库升级:既有行保留、force=false 默认、新列可写', () async {
final db = legacyV1Db(); final db = legacyV1Db();
@@ -617,5 +625,49 @@ void main() {
); );
expect(created.force, isTrue); expect(created.force, isTrue);
}); });
test('降级重开后再升级:force 列已存在不重复补列(旧 APK 覆盖安装防护)',
() async {
final tmp = await Directory.systemTemp.createTemp('store-mig-');
addTearDown(() async {
try {
await tmp.delete(recursive: true);
} on Object {
// Windows 句柄延迟释放:残留交由系统临时目录清理。
}
});
final dbFile = File('${tmp.path}/upload_tasks.sqlite');
// ① 新 APK 打开 v1 底库:升级补 force 列(user_version → 2)。
final first = TaskStore(
UploadTaskDb(NativeDatabase(dbFile, setup: runSeed)),
);
expect((await first.get('alice', 'task-legacy'))!.force, isFalse,
reason: '前置:v1 → v2 升级成功');
await first.close();
// ② 模拟旧 APK 覆盖安装后的降级打开残留态:drift 降级路径不删列、
// 只把 user_version 写回 1(force 列留存)——setup 直接置 1。
// ③ 再装回新 APK:from=1 重跑 onUpgrade——不得因列已存在抛
// 「duplicate column name: force」致库打不开(测试服务器工作流:
// 测试员旧包覆盖装新包的常态路径)。
final second = TaskStore(
UploadTaskDb(
NativeDatabase(
dbFile,
setup: (raw) => raw.execute('PRAGMA user_version = 1;'),
),
),
);
addTearDown(second.close);
final legacy = await second.get('alice', 'task-legacy');
expect(legacy, isNotNull, reason: '降级-重升级循环不丢既有行');
expect(legacy!.filename, 'IMG_LEGACY.jpg');
final created = await second.create(
draft(id: 'task-after', clientTaskId: 'ct-after', force: true),
);
expect(created.force, isTrue, reason: '重升级后新列仍可写');
});
}); });
} }