fix(mobile): clear linkedRemoteAlbumId in reset() so FK refs dont dangle (#28382)

* fix(mobile): clear linkedRemoteAlbumId in reset() so FK refs dont dangle

reset() runs with foreign_keys off before wiping remote_* tables, so the ON DELETE SET NULL cascade on linkedRemoteAlbumId doesnt fire. local rows keep pointing at deleted remote ids.

affects logout (clearLocalData calls reset()) and the server SyncResetV1 path (30 day idle, etc). after re-login, syncLinkedAlbum either silently warns or fires 400s (those are covered by #28299).

null the column manually inside the same transaction. cascade still works for normal SyncAlbumDeleteV1.

verified on pixel 9a with this branch built locally: logged out, deleted album from web, logged back in. without fix linkedRemoteAlbumId stayed dangling. with fix all three local rows have linkedRemoteAlbumId = NULL after the logout reset, and recovery is clean once manageLinkedAlbums runs again.

* fix(mobile): always re-enable foreign_keys in reset() + simplify the update

re-enable foreign_keys inside a try/finally so it always runs even if the transaction throws. without this, a failed reset would leave the connection with foreign_keys = OFF and silently disable cascades for everything after (per copilot review).

also drop the where filter on the linkedRemoteAlbumId update, unconditional update-all is simpler and we wipe everything in reset anyway (per ganka review).
This commit is contained in:
Santo Shakil
2026-05-13 00:43:15 +06:00
committed by GitHub
parent 2ddaf6a611
commit f632d320f5
2 changed files with 86 additions and 19 deletions
@@ -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);
@@ -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));
});
});
}