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(
|
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: '重升级后新列仍可写');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user