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:
@@ -141,7 +141,20 @@ class UploadTaskDb extends _$UploadTaskDb {
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onUpgrade: (m, from, to) async {
|
||||
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 文本落库的亚秒精度。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:drift/native.dart' show NativeDatabase;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -558,46 +560,52 @@ void main() {
|
||||
});
|
||||
|
||||
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——升级后打开。
|
||||
UploadTaskDb legacyV1Db() => UploadTaskDb(
|
||||
NativeDatabase.memory(setup: (raw) {
|
||||
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;');
|
||||
}),
|
||||
);
|
||||
UploadTaskDb legacyV1Db() =>
|
||||
UploadTaskDb(NativeDatabase.memory(setup: runSeed));
|
||||
|
||||
test('旧库升级:既有行保留、force=false 默认、新列可写', () async {
|
||||
final db = legacyV1Db();
|
||||
@@ -617,5 +625,49 @@ void main() {
|
||||
);
|
||||
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: '重升级后新列仍可写');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user