From be349426eeb479ffc38c950d27725e0c25c8970d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 25 Oct 2026 12:51:04 +0300 Subject: [PATCH] fix(storage): the legacy profile migration puts querya.db in last and whole, so an interrupted copy is retried (#1766) --- lib/core/storage/app_data_root.dart | 17 +++++++ test/core/storage/app_data_root_test.dart | 55 +++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/lib/core/storage/app_data_root.dart b/lib/core/storage/app_data_root.dart index c708f404..c08c3d8b 100644 --- a/lib/core/storage/app_data_root.dart +++ b/lib/core/storage/app_data_root.dart @@ -194,19 +194,36 @@ abstract final class AppDataRoot { return const []; } + /// Copies [from] into [to] so that `querya_desktop/querya.db` appears last + /// and whole: the migration is skipped once that file exists, so an + /// interrupted copy must never leave it behind. Its `-wal` / `-shm` files + /// are copied before it, and the database goes in under a temporary name + /// and is renamed into place. [from] is not touched. static Future _copyDirectory(Directory from, Directory to) async { if (!await to.exists()) { await to.create(recursive: true); } + final dbRelative = p.join('querya_desktop', 'querya.db'); + File? legacyDb; await for (final entity in from.list(recursive: true, followLinks: false)) { final relative = p.relative(entity.path, from: from.path); final destPath = p.join(to.path, relative); if (entity is Directory) { await Directory(destPath).create(recursive: true); } else if (entity is File) { + if (relative == dbRelative) { + legacyDb = entity; + continue; + } await File(destPath).parent.create(recursive: true); await entity.copy(destPath); } } + if (legacyDb == null) return; + final destDb = p.join(to.path, dbRelative); + await Directory(p.dirname(destDb)).create(recursive: true); + final staged = '$destDb.migrating'; + await legacyDb.copy(staged); + await File(staged).rename(destDb); } } diff --git a/test/core/storage/app_data_root_test.dart b/test/core/storage/app_data_root_test.dart index b52d44ff..5b3961d1 100644 --- a/test/core/storage/app_data_root_test.dart +++ b/test/core/storage/app_data_root_test.dart @@ -159,5 +159,60 @@ void main() { ); expect(again, isFalse); }); + + test('a copy that fails midway leaves no querya.db and is retried (#1766)', + () async { + final legacy = Directory(p.join(tempDir.path, 'legacy_support')); + final next = Directory(p.join(tempDir.path, 'new_support')); + await Directory(p.join(legacy.path, 'querya_desktop')) + .create(recursive: true); + await File(p.join(legacy.path, 'querya_desktop', 'querya.db')) + .writeAsString('legacy-db'); + await File(p.join(legacy.path, 'querya_desktop', 'querya.db-wal')) + .writeAsString('legacy-wal'); + await Directory(p.join(legacy.path, 'themes')).create(recursive: true); + await File(p.join(legacy.path, 'themes', 'a.json')).writeAsString('{}'); + // A directory where a file has to go makes that copy fail. + await Directory(p.join(next.path, 'themes', 'a.json')) + .create(recursive: true); + + await expectLater( + AppDataRoot.migrateLegacySupportIfNeeded( + newSupport: next, + legacyCandidates: [legacy], + ), + throwsA(isA()), + ); + expect( + await File(p.join(next.path, 'querya_desktop', 'querya.db')).exists(), + isFalse, + ); + + await Directory(p.join(next.path, 'themes', 'a.json')).delete(); + final retried = await AppDataRoot.migrateLegacySupportIfNeeded( + newSupport: next, + legacyCandidates: [legacy], + ); + expect(retried, isTrue); + expect( + await File(p.join(next.path, 'querya_desktop', 'querya.db')) + .readAsString(), + 'legacy-db', + ); + expect( + await File(p.join(next.path, 'querya_desktop', 'querya.db-wal')) + .readAsString(), + 'legacy-wal', + ); + expect( + await File(p.join(next.path, 'querya_desktop', 'querya.db.migrating')) + .exists(), + isFalse, + ); + expect( + await File(p.join(legacy.path, 'querya_desktop', 'querya.db')).exists(), + isTrue, + ); + }); }); }