diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index ee859cc2dd..b7593c3202 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.da import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; @@ -45,25 +46,35 @@ class SyncStreamRepository extends DriftDatabaseRepository { // foreign_keys PRAGMA is no-op within transactions // https://www.sqlite.org/pragma.html#pragma_foreign_keys await _db.customStatement('PRAGMA foreign_keys = OFF'); - await transaction(() async { - await _db.assetFaceEntity.deleteAll(); - await _db.memoryAssetEntity.deleteAll(); - await _db.memoryEntity.deleteAll(); - await _db.partnerEntity.deleteAll(); - await _db.personEntity.deleteAll(); - await _db.remoteAlbumAssetEntity.deleteAll(); - await _db.remoteAlbumEntity.deleteAll(); - await _db.remoteAlbumUserEntity.deleteAll(); - await _db.remoteAssetEntity.deleteAll(); - await _db.remoteExifEntity.deleteAll(); - await _db.stackEntity.deleteAll(); - await _db.authUserEntity.deleteAll(); - await _db.userEntity.deleteAll(); - await _db.userMetadataEntity.deleteAll(); - await _db.remoteAssetCloudIdEntity.deleteAll(); - await _db.assetEditEntity.deleteAll(); - }); - await _db.customStatement('PRAGMA foreign_keys = ON'); + try { + await transaction(() async { + // FK cascade (ON DELETE SET NULL) does not fire while foreign_keys = OFF, + // so null linkedRemoteAlbumId manually to avoid dangling pointers in local_album_entity. + await _db.localAlbumEntity.update().write( + const LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(null)), + ); + await _db.assetFaceEntity.deleteAll(); + await _db.memoryAssetEntity.deleteAll(); + await _db.memoryEntity.deleteAll(); + await _db.partnerEntity.deleteAll(); + await _db.personEntity.deleteAll(); + await _db.remoteAlbumAssetEntity.deleteAll(); + await _db.remoteAlbumEntity.deleteAll(); + await _db.remoteAlbumUserEntity.deleteAll(); + await _db.remoteAssetEntity.deleteAll(); + await _db.remoteExifEntity.deleteAll(); + await _db.stackEntity.deleteAll(); + await _db.authUserEntity.deleteAll(); + await _db.userEntity.deleteAll(); + await _db.userMetadataEntity.deleteAll(); + await _db.remoteAssetCloudIdEntity.deleteAll(); + await _db.assetEditEntity.deleteAll(); + }); + } finally { + // re-enable FK even if the transaction throws, otherwise the connection + // would be left with foreign_keys = OFF, silently disabling cascades. + await _db.customStatement('PRAGMA foreign_keys = ON'); + } }); } catch (error, stack) { _logger.severe('Error: SyncResetV1', error, stack); diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index 39d6d9156e..4199a5b756 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -1,6 +1,10 @@ import 'package:drift/drift.dart' as drift; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:openapi/api.dart'; @@ -184,4 +188,56 @@ void main() { expect(result.height, equals(existingHeight), reason: 'Height should remain as originally set'); }); }); + + group('SyncStreamRepository - reset()', () { + test('nulls linkedRemoteAlbumId on localAlbumEntity so FK refs do not dangle', () async { + const localAlbumId = 'local-1'; + const remoteAlbumId = 'remote-1'; + + await db.remoteAlbumEntity.insertOne( + RemoteAlbumEntityCompanion.insert(id: remoteAlbumId, name: 'Movies', order: AlbumAssetOrder.desc), + ); + await db.localAlbumEntity.insertOne( + LocalAlbumEntityCompanion.insert( + id: localAlbumId, + name: 'Movies', + backupSelection: BackupSelection.selected, + linkedRemoteAlbumId: const drift.Value(remoteAlbumId), + ), + ); + + // sanity: link is set before reset + final before = await (db.localAlbumEntity.select()..where((t) => t.id.equals(localAlbumId))).getSingle(); + expect(before.linkedRemoteAlbumId, equals(remoteAlbumId)); + + await sut.reset(); + + final after = await (db.localAlbumEntity.select()..where((t) => t.id.equals(localAlbumId))).getSingle(); + expect( + after.linkedRemoteAlbumId, + isNull, + reason: + 'reset() runs with PRAGMA foreign_keys = OFF so the ON DELETE SET NULL cascade does not fire — the link must be nulled manually', + ); + expect(after.name, equals('Movies'), reason: 'local album row itself must be preserved'); + expect(after.backupSelection, equals(BackupSelection.selected)); + + final remoteRows = await db.remoteAlbumEntity.select().get(); + expect(remoteRows, isEmpty, reason: 'reset() still wipes remoteAlbumEntity'); + }); + + test('preserves localAlbumEntity rows that have no linkedRemoteAlbumId', () async { + const localAlbumId = 'local-unlinked'; + await db.localAlbumEntity.insertOne( + LocalAlbumEntityCompanion.insert(id: localAlbumId, name: 'Camera', backupSelection: BackupSelection.none), + ); + + await sut.reset(); + + final after = await (db.localAlbumEntity.select()..where((t) => t.id.equals(localAlbumId))).getSingle(); + expect(after.linkedRemoteAlbumId, isNull); + expect(after.name, equals('Camera')); + expect(after.backupSelection, equals(BackupSelection.none)); + }); + }); }