From 037b6238f2a76c7942d9762f4f2175318042eee4 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Fri, 15 May 2026 23:31:38 +0600 Subject: [PATCH 1/4] fix(mobile): dedupe stale remote_asset rows on sync queue a pre-delete on the matching partial-index tuple before each upsert in updateAssetsV1/V2 so the second insert does not crash on SqliteException(2067) when the server re-issues a new id for the same (ownerId, checksum). closes #22522 #27186. --- .../repositories/sync_stream.repository.dart | 52 ++++++++ .../sync_stream_repository_test.dart | 112 +++++++++++++++++- 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index c43de69c5d..24d8877c7b 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -199,6 +199,16 @@ class SyncStreamRepository extends DriftDatabaseRepository { try { await _db.batch((batch) { for (final asset in data) { + // Avoid SqliteException(2067) when server re-issues a new id for + // the same (ownerId, checksum). #22522 #27186 + _enqueueRemoteAssetDedupe( + batch, + id: asset.id, + ownerId: asset.ownerId, + checksum: asset.checksum, + libraryId: asset.libraryId, + ); + final companion = RemoteAssetEntityCompanion( name: Value(asset.originalFileName), type: Value(asset.type.toAssetType()), @@ -238,6 +248,15 @@ class SyncStreamRepository extends DriftDatabaseRepository { try { await _db.batch((batch) { for (final asset in data) { + // See updateAssetsV1 for why this dedupe is required. #22522 #27186 + _enqueueRemoteAssetDedupe( + batch, + id: asset.id, + ownerId: asset.ownerId, + checksum: asset.checksum, + libraryId: asset.libraryId, + ); + final companion = RemoteAssetEntityCompanion( name: Value(asset.originalFileName), type: Value(asset.type.toAssetType()), @@ -273,6 +292,39 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } + /// Queues a DELETE that prunes any stale remote_asset row matching the + /// partial UNIQUE index for the incoming asset: + /// - libraryId IS NULL -> (owner_id, checksum) + /// - libraryId NOT NULL -> (owner_id, library_id, checksum) + /// The current id is excluded so a same-id update does not delete itself. + void _enqueueRemoteAssetDedupe( + Batch batch, { + required String id, + required String ownerId, + required String checksum, + required String? libraryId, + }) { + if (libraryId == null) { + batch.deleteWhere( + _db.remoteAssetEntity, + (row) => + row.ownerId.equals(ownerId) & + row.checksum.equals(checksum) & + row.libraryId.isNull() & + row.id.equals(id).not(), + ); + } else { + batch.deleteWhere( + _db.remoteAssetEntity, + (row) => + row.ownerId.equals(ownerId) & + row.checksum.equals(checksum) & + row.libraryId.equals(libraryId) & + row.id.equals(id).not(), + ); + } + } + Future updateAssetsExifV1(Iterable data, {String debugLabel = 'user'}) async { try { await _db.batch((batch) { diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index bd47f63da5..fd593b7542 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -28,6 +28,7 @@ SyncAssetV1 _createAsset({ String ownerId = 'user-1', int? width, int? height, + String? libraryId, }) { return SyncAssetV1( id: id, @@ -45,7 +46,38 @@ SyncAssetV1 _createAsset({ height: height, deletedAt: null, duration: null, - libraryId: null, + libraryId: libraryId, + livePhotoVideoId: null, + stackId: null, + thumbhash: null, + isEdited: false, + ); +} + +SyncAssetV2 _createAssetV2({ + required String id, + required String checksum, + required String fileName, + String ownerId = 'user-1', + String? libraryId, +}) { + return SyncAssetV2( + id: id, + checksum: checksum, + originalFileName: fileName, + type: AssetTypeEnum.IMAGE, + ownerId: ownerId, + isFavorite: false, + fileCreatedAt: DateTime(2024, 1, 1), + fileModifiedAt: DateTime(2024, 1, 1), + createdAt: DateTime(2024, 1, 1), + localDateTime: DateTime(2024, 1, 1), + visibility: AssetVisibility.timeline, + width: null, + height: null, + deletedAt: null, + duration: 0, + libraryId: libraryId, livePhotoVideoId: null, stackId: null, thumbhash: null, @@ -240,4 +272,82 @@ void main() { expect(after.backupSelection, equals(BackupSelection.none)); }); }); + + group('SyncStreamRepository - updateAssetsV1 dedupe (#22522 #27186)', () { + test('replaces stale row when new id arrives with same (ownerId, checksum) and library is null', () async { + await sut.updateUsersV1([_createUser()]); + await sut.updateAssetsV1([_createAsset(id: 'old-id', checksum: 'AAA', fileName: 'photo.jpg')]); + + // Server re-issues a new id for the same content (replace-with-upload, immich-go, etc.) + await sut.updateAssetsV1([_createAsset(id: 'new-id', checksum: 'AAA', fileName: 'photo.jpg')]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(1)); + expect(rows.single.id, equals('new-id')); + expect(rows.single.checksum, equals('AAA')); + }); + + test('replaces stale row by (ownerId, libraryId, checksum) when library is not null', () async { + await sut.updateUsersV1([_createUser()]); + await sut.updateAssetsV1([ + _createAsset(id: 'old-id', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), + ]); + + await sut.updateAssetsV1([ + _createAsset(id: 'new-id', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), + ]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(1)); + expect(rows.single.id, equals('new-id')); + expect(rows.single.libraryId, equals('lib-1')); + }); + + test('library and non-library rows with same (ownerId, checksum) coexist', () async { + await sut.updateUsersV1([_createUser()]); + await sut.updateAssetsV1([ + _createAsset(id: 'lib-row', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), + _createAsset(id: 'main-row', checksum: 'AAA', fileName: 'photo.jpg'), + ]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(2), reason: 'library NULL and NOT NULL match different partial indexes'); + expect(rows.map((r) => r.id).toSet(), equals({'lib-row', 'main-row'})); + }); + + test('different owners with same checksum coexist', () async { + await sut.updateUsersV1([_createUser(id: 'user-1')]); + await sut.updateUsersV1([_createUser(id: 'user-2')]); + await sut.updateAssetsV1([ + _createAsset(id: 'a-id', checksum: 'AAA', fileName: 'photo.jpg', ownerId: 'user-1'), + _createAsset(id: 'b-id', checksum: 'AAA', fileName: 'photo.jpg', ownerId: 'user-2'), + ]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(2)); + }); + + test('same id arriving again updates in place (no self-delete)', () async { + await sut.updateUsersV1([_createUser()]); + await sut.updateAssetsV1([_createAsset(id: 'same-id', checksum: 'AAA', fileName: 'photo.jpg')]); + + await sut.updateAssetsV1([_createAsset(id: 'same-id', checksum: 'AAA', fileName: 'renamed.jpg')]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(1)); + expect(rows.single.id, equals('same-id')); + expect(rows.single.name, equals('renamed.jpg'), reason: 'ON CONFLICT(id) DO UPDATE path still works'); + }); + + test('updateAssetsV2 dedupes the same way', () async { + await sut.updateUsersV1([_createUser()]); + await sut.updateAssetsV2([_createAssetV2(id: 'old-id', checksum: 'AAA', fileName: 'photo.jpg')]); + + await sut.updateAssetsV2([_createAssetV2(id: 'new-id', checksum: 'AAA', fileName: 'photo.jpg')]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(1)); + expect(rows.single.id, equals('new-id')); + }); + }); } From c32426240cc7cbceadbcb6abe1164bb69ff22f5e Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Tue, 4 Aug 2026 17:47:58 +0600 Subject: [PATCH 2/4] run the dedupe deletes before the inserts and keep only the last duplicate per payload --- .../repositories/sync_stream.repository.dart | 22 +++++++++++++--- .../sync_stream_repository_test.dart | 25 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 24d8877c7b..00b3304087 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -198,9 +198,15 @@ class SyncStreamRepository extends DriftDatabaseRepository { Future updateAssetsV1(Iterable data, {String debugLabel = 'user'}) async { try { await _db.batch((batch) { + // Keep only the last asset per partial-index key; server order is authoritative + final deduped = <(String, String, String?), SyncAssetV1>{}; for (final asset in data) { - // Avoid SqliteException(2067) when server re-issues a new id for - // the same (ownerId, checksum). #22522 #27186 + deduped[(asset.ownerId, asset.checksum, asset.libraryId)] = asset; + } + + // Avoid SqliteException(2067) when server re-issues a new id for + // the same (ownerId, checksum). #22522 #27186 + for (final asset in deduped.values) { _enqueueRemoteAssetDedupe( batch, id: asset.id, @@ -208,7 +214,9 @@ class SyncStreamRepository extends DriftDatabaseRepository { checksum: asset.checksum, libraryId: asset.libraryId, ); + } + for (final asset in deduped.values) { final companion = RemoteAssetEntityCompanion( name: Value(asset.originalFileName), type: Value(asset.type.toAssetType()), @@ -247,8 +255,14 @@ class SyncStreamRepository extends DriftDatabaseRepository { Future updateAssetsV2(Iterable data, {String debugLabel = 'user'}) async { try { await _db.batch((batch) { + // Keep only the last asset per partial-index key; server order is authoritative + final deduped = <(String, String, String?), SyncAssetV2>{}; for (final asset in data) { - // See updateAssetsV1 for why this dedupe is required. #22522 #27186 + deduped[(asset.ownerId, asset.checksum, asset.libraryId)] = asset; + } + + // See updateAssetsV1 for why this dedupe is required. #22522 #27186 + for (final asset in deduped.values) { _enqueueRemoteAssetDedupe( batch, id: asset.id, @@ -256,7 +270,9 @@ class SyncStreamRepository extends DriftDatabaseRepository { checksum: asset.checksum, libraryId: asset.libraryId, ); + } + for (final asset in deduped.values) { final companion = RemoteAssetEntityCompanion( name: Value(asset.originalFileName), type: Value(asset.type.toAssetType()), diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index fd593b7542..9761b1c9cc 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -349,5 +349,30 @@ void main() { expect(rows, hasLength(1)); expect(rows.single.id, equals('new-id')); }); + + test('one payload with duplicate (ownerId, checksum) and null library keeps the last asset', () async { + await sut.updateUsersV1([_createUser()]); + await sut.updateAssetsV1([ + _createAsset(id: 'first-id', checksum: 'AAA', fileName: 'photo.jpg'), + _createAsset(id: 'last-id', checksum: 'AAA', fileName: 'photo.jpg'), + ]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(1)); + expect(rows.single.id, equals('last-id')); + }); + + test('one payload with duplicate (ownerId, libraryId, checksum) keeps the last asset', () async { + await sut.updateUsersV1([_createUser()]); + await sut.updateAssetsV1([ + _createAsset(id: 'first-id', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), + _createAsset(id: 'last-id', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), + ]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(1)); + expect(rows.single.id, equals('last-id')); + expect(rows.single.libraryId, equals('lib-1')); + }); }); } From e39b1b3b73474ad38456e3fb43e4592caccaed68 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Wed, 5 Aug 2026 12:19:48 +0600 Subject: [PATCH 3/4] switch the asset upsert to insert or replace --- .../repositories/sync_stream.repository.dart | 70 +---- .../sync_stream_repository_test.dart | 296 +++++++++++++----- 2 files changed, 219 insertions(+), 147 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 00b3304087..fc3f8f9314 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -198,25 +198,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { Future updateAssetsV1(Iterable data, {String debugLabel = 'user'}) async { try { await _db.batch((batch) { - // Keep only the last asset per partial-index key; server order is authoritative - final deduped = <(String, String, String?), SyncAssetV1>{}; for (final asset in data) { - deduped[(asset.ownerId, asset.checksum, asset.libraryId)] = asset; - } - - // Avoid SqliteException(2067) when server re-issues a new id for - // the same (ownerId, checksum). #22522 #27186 - for (final asset in deduped.values) { - _enqueueRemoteAssetDedupe( - batch, - id: asset.id, - ownerId: asset.ownerId, - checksum: asset.checksum, - libraryId: asset.libraryId, - ); - } - - for (final asset in deduped.values) { final companion = RemoteAssetEntityCompanion( name: Value(asset.originalFileName), type: Value(asset.type.toAssetType()), @@ -242,6 +224,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { batch.insert( _db.remoteAssetEntity, companion.copyWith(id: Value(asset.id)), + mode: InsertMode.insertOrReplace, onConflict: DoUpdate((_) => companion), ); } @@ -255,24 +238,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { Future updateAssetsV2(Iterable data, {String debugLabel = 'user'}) async { try { await _db.batch((batch) { - // Keep only the last asset per partial-index key; server order is authoritative - final deduped = <(String, String, String?), SyncAssetV2>{}; for (final asset in data) { - deduped[(asset.ownerId, asset.checksum, asset.libraryId)] = asset; - } - - // See updateAssetsV1 for why this dedupe is required. #22522 #27186 - for (final asset in deduped.values) { - _enqueueRemoteAssetDedupe( - batch, - id: asset.id, - ownerId: asset.ownerId, - checksum: asset.checksum, - libraryId: asset.libraryId, - ); - } - - for (final asset in deduped.values) { final companion = RemoteAssetEntityCompanion( name: Value(asset.originalFileName), type: Value(asset.type.toAssetType()), @@ -298,6 +264,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { batch.insert( _db.remoteAssetEntity, companion.copyWith(id: Value(asset.id)), + mode: InsertMode.insertOrReplace, onConflict: DoUpdate((_) => companion), ); } @@ -308,39 +275,6 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } - /// Queues a DELETE that prunes any stale remote_asset row matching the - /// partial UNIQUE index for the incoming asset: - /// - libraryId IS NULL -> (owner_id, checksum) - /// - libraryId NOT NULL -> (owner_id, library_id, checksum) - /// The current id is excluded so a same-id update does not delete itself. - void _enqueueRemoteAssetDedupe( - Batch batch, { - required String id, - required String ownerId, - required String checksum, - required String? libraryId, - }) { - if (libraryId == null) { - batch.deleteWhere( - _db.remoteAssetEntity, - (row) => - row.ownerId.equals(ownerId) & - row.checksum.equals(checksum) & - row.libraryId.isNull() & - row.id.equals(id).not(), - ); - } else { - batch.deleteWhere( - _db.remoteAssetEntity, - (row) => - row.ownerId.equals(ownerId) & - row.checksum.equals(checksum) & - row.libraryId.equals(libraryId) & - row.id.equals(id).not(), - ); - } - } - Future updateAssetsExifV1(Iterable data, {String debugLabel = 'user'}) async { try { await _db.batch((batch) { diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index 9761b1c9cc..9111e376c2 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -3,16 +3,23 @@ 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/domain/models/asset/base_asset.model.dart' as domain; +import 'package:immich_mobile/domain/models/memory.model.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_face.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/remote_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album_asset.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'; -SyncUserV1 _createUser({String id = 'user-1'}) { +SyncUserV1 _createUser({String id = 'user-1', String name = 'Test User'}) { return SyncUserV1( id: id, - name: 'Test User', + name: name, email: 'test@test.com', deletedAt: null, avatarColor: const Optional.absent(), @@ -29,6 +36,14 @@ SyncAssetV1 _createAsset({ int? width, int? height, String? libraryId, + bool isFavorite = false, + AssetVisibility visibility = AssetVisibility.timeline, + String? stackId, + String? livePhotoVideoId, + String? thumbhash, + DateTime? deletedAt, + DateTime? fileCreatedAt, + DateTime? fileModifiedAt, }) { return SyncAssetV1( id: id, @@ -36,20 +51,20 @@ SyncAssetV1 _createAsset({ originalFileName: fileName, type: AssetTypeEnum.IMAGE, ownerId: ownerId, - isFavorite: false, - fileCreatedAt: DateTime(2024, 1, 1), - fileModifiedAt: DateTime(2024, 1, 1), + isFavorite: isFavorite, + fileCreatedAt: fileCreatedAt ?? DateTime(2024, 1, 1), + fileModifiedAt: fileModifiedAt ?? DateTime(2024, 1, 1), createdAt: DateTime(2024, 1, 1), localDateTime: DateTime(2024, 1, 1), - visibility: AssetVisibility.timeline, + visibility: visibility, width: width, height: height, - deletedAt: null, + deletedAt: deletedAt, duration: null, libraryId: libraryId, - livePhotoVideoId: null, - stackId: null, - thumbhash: null, + livePhotoVideoId: livePhotoVideoId, + stackId: stackId, + thumbhash: thumbhash, isEdited: false, ); } @@ -273,106 +288,229 @@ void main() { }); }); - group('SyncStreamRepository - updateAssetsV1 dedupe (#22522 #27186)', () { - test('replaces stale row when new id arrives with same (ownerId, checksum) and library is null', () async { + group('SyncStreamRepository - updateAssets upsert dedupe (#22522 #27186)', () { + Future pragmaOn(String testName) async { + await db.customStatement('PRAGMA foreign_keys = ON'); + final row = await db.customSelect('PRAGMA foreign_keys').getSingle(); + // ignore: avoid_print + print('RECEIPT test=$testName pragma foreign_keys=${row.read('foreign_keys')}'); + } + + Future seedChildren(String assetId, String tag) async { + await db.remoteExifEntity.insertOne(RemoteExifEntityCompanion.insert(assetId: assetId)); + await db.remoteAlbumEntity.insertOne( + RemoteAlbumEntityCompanion.insert(id: 'album-$tag', name: 'Album', order: AlbumAssetOrder.desc), + ); + await db.remoteAlbumAssetEntity.insertOne( + RemoteAlbumAssetEntityCompanion.insert(assetId: assetId, albumId: 'album-$tag'), + ); + await db.memoryEntity.insertOne( + MemoryEntityCompanion.insert( + id: 'memory-$tag', + ownerId: 'user-1', + type: MemoryTypeEnum.onThisDay, + data: '{"year":2024}', + memoryAt: DateTime(2024, 1, 1), + ), + ); + await db.memoryAssetEntity.insertOne( + MemoryAssetEntityCompanion.insert(assetId: assetId, memoryId: 'memory-$tag'), + ); + await db.assetFaceEntity.insertOne( + AssetFaceEntityCompanion.insert( + id: 'face-$tag', + assetId: assetId, + imageWidth: 100, + imageHeight: 100, + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 10, + boundingBoxY2: 10, + sourceType: 'ml', + ), + ); + } + + Future countChildren(String assetId) async { + final exif = await (db.remoteExifEntity.select()..where((t) => t.assetId.equals(assetId))).get(); + final albumAsset = await (db.remoteAlbumAssetEntity.select()..where((t) => t.assetId.equals(assetId))).get(); + final memoryAsset = await (db.memoryAssetEntity.select()..where((t) => t.assetId.equals(assetId))).get(); + final faces = await (db.assetFaceEntity.select()..where((t) => t.assetId.equals(assetId))).get(); + return exif.length + albumAsset.length + memoryAsset.length + faces.length; + } + + test('same-id update keeps children and updates fields', () async { + await pragmaOn('same-id'); await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([_createAsset(id: 'old-id', checksum: 'AAA', fileName: 'photo.jpg')]); + await sut.updateAssetsV1([_createAsset(id: 'a', checksum: 'AAA', fileName: 'photo.jpg')]); + await seedChildren('a', 't1'); - // Server re-issues a new id for the same content (replace-with-upload, immich-go, etc.) - await sut.updateAssetsV1([_createAsset(id: 'new-id', checksum: 'AAA', fileName: 'photo.jpg')]); + await sut.updateAssetsV1([_createAsset(id: 'a', checksum: 'AAA', fileName: 'renamed.jpg', isFavorite: true)]); - final rows = await db.remoteAssetEntity.select().get(); - expect(rows, hasLength(1)); - expect(rows.single.id, equals('new-id')); - expect(rows.single.checksum, equals('AAA')); + expect(await countChildren('a'), 4, reason: 'DO UPDATE keeps the row, children survive'); + final row = await (db.remoteAssetEntity.select()..where((t) => t.id.equals('a'))).getSingle(); + expect(row.name, 'renamed.jpg'); + expect(row.isFavorite, isTrue); }); - test('replaces stale row by (ownerId, libraryId, checksum) when library is not null', () async { + test('reupload with a new id replaces the stale row and cascades its children', () async { + await pragmaOn('reupload'); + await sut.updateUsersV1([_createUser()]); + await sut.updateAssetsV1([_createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg')]); + await seedChildren('stale', 't2'); + + await sut.updateAssetsV1([_createAsset(id: 'fresh', checksum: 'AAA', fileName: 'photo.jpg')]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows.map((r) => r.id), ['fresh'], reason: 'no 2067, stale row replaced away'); + expect(await countChildren('stale'), 0, reason: 'stale children cascade with the replaced row'); + + // same scenario through V2 + await sut.updateAssetsV2([_createAssetV2(id: 'stale2', checksum: 'BBB', fileName: 'photo2.jpg')]); + await seedChildren('stale2', 't2b'); + await sut.updateAssetsV2([_createAssetV2(id: 'fresh2', checksum: 'BBB', fileName: 'photo2.jpg')]); + + final rows2 = await db.remoteAssetEntity.select().get(); + expect(rows2.map((r) => r.id), containsAllInOrder(['fresh', 'fresh2'])); + expect(await countChildren('stale2'), 0); + }); + + test('library variant replaces only the matching library row', () async { + await pragmaOn('library'); await sut.updateUsersV1([_createUser()]); await sut.updateAssetsV1([ - _createAsset(id: 'old-id', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), + _createAsset(id: 'stale-lib', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), + _createAsset(id: 'keep-null', checksum: 'AAA', fileName: 'photo.jpg'), ]); await sut.updateAssetsV1([ - _createAsset(id: 'new-id', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), + _createAsset(id: 'fresh-lib', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), ]); final rows = await db.remoteAssetEntity.select().get(); - expect(rows, hasLength(1)); - expect(rows.single.id, equals('new-id')); - expect(rows.single.libraryId, equals('lib-1')); + expect(rows.map((r) => r.id).toSet(), { + 'fresh-lib', + 'keep-null', + }, reason: 'library NULL and NOT NULL match different partial indexes'); }); - test('library and non-library rows with same (ownerId, checksum) coexist', () async { + test('batch-internal duplicates keep the last payload asset', () async { + await pragmaOn('batch-dupes'); await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([ - _createAsset(id: 'lib-row', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), - _createAsset(id: 'main-row', checksum: 'AAA', fileName: 'photo.jpg'), - ]); - final rows = await db.remoteAssetEntity.select().get(); - expect(rows, hasLength(2), reason: 'library NULL and NOT NULL match different partial indexes'); - expect(rows.map((r) => r.id).toSet(), equals({'lib-row', 'main-row'})); - }); - - test('different owners with same checksum coexist', () async { - await sut.updateUsersV1([_createUser(id: 'user-1')]); - await sut.updateUsersV1([_createUser(id: 'user-2')]); - await sut.updateAssetsV1([ - _createAsset(id: 'a-id', checksum: 'AAA', fileName: 'photo.jpg', ownerId: 'user-1'), - _createAsset(id: 'b-id', checksum: 'AAA', fileName: 'photo.jpg', ownerId: 'user-2'), - ]); - - final rows = await db.remoteAssetEntity.select().get(); - expect(rows, hasLength(2)); - }); - - test('same id arriving again updates in place (no self-delete)', () async { - await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([_createAsset(id: 'same-id', checksum: 'AAA', fileName: 'photo.jpg')]); - - await sut.updateAssetsV1([_createAsset(id: 'same-id', checksum: 'AAA', fileName: 'renamed.jpg')]); - - final rows = await db.remoteAssetEntity.select().get(); - expect(rows, hasLength(1)); - expect(rows.single.id, equals('same-id')); - expect(rows.single.name, equals('renamed.jpg'), reason: 'ON CONFLICT(id) DO UPDATE path still works'); - }); - - test('updateAssetsV2 dedupes the same way', () async { - await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV2([_createAssetV2(id: 'old-id', checksum: 'AAA', fileName: 'photo.jpg')]); - - await sut.updateAssetsV2([_createAssetV2(id: 'new-id', checksum: 'AAA', fileName: 'photo.jpg')]); - - final rows = await db.remoteAssetEntity.select().get(); - expect(rows, hasLength(1)); - expect(rows.single.id, equals('new-id')); - }); - - test('one payload with duplicate (ownerId, checksum) and null library keeps the last asset', () async { - await sut.updateUsersV1([_createUser()]); await sut.updateAssetsV1([ _createAsset(id: 'first-id', checksum: 'AAA', fileName: 'photo.jpg'), _createAsset(id: 'last-id', checksum: 'AAA', fileName: 'photo.jpg'), + _createAsset(id: 'first-lib', checksum: 'BBB', fileName: 'photo.jpg', libraryId: 'lib-1'), + _createAsset(id: 'last-lib', checksum: 'BBB', fileName: 'photo.jpg', libraryId: 'lib-1'), ]); final rows = await db.remoteAssetEntity.select().get(); - expect(rows, hasLength(1)); - expect(rows.single.id, equals('last-id')); + expect(rows, hasLength(2), reason: 'REPLACE makes batch-internal duplicates last-wins, no crash'); + expect(rows.map((r) => r.id).toSet(), {'last-id', 'last-lib'}); }); - test('one payload with duplicate (ownerId, libraryId, checksum) keeps the last asset', () async { + test('mixed batch of new, updated, and colliding assets all land', () async { + await pragmaOn('mixed'); await sut.updateUsersV1([_createUser()]); await sut.updateAssetsV1([ - _createAsset(id: 'first-id', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), - _createAsset(id: 'last-id', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), + _createAsset(id: 'up', checksum: 'UUU', fileName: 'up.jpg'), + _createAsset(id: 'stale', checksum: 'CCC', fileName: 'collide.jpg'), + ]); + await seedChildren('up', 't5a'); + await seedChildren('stale', 't5b'); + + await sut.updateAssetsV1([ + _createAsset(id: 'up', checksum: 'UUU', fileName: 'up-renamed.jpg'), + _createAsset(id: 'new', checksum: 'NNN', fileName: 'new.jpg'), + _createAsset(id: 'fresh', checksum: 'CCC', fileName: 'collide.jpg'), ]); final rows = await db.remoteAssetEntity.select().get(); + expect(rows.map((r) => r.id).toSet(), {'up', 'new', 'fresh'}); + expect(rows.singleWhere((r) => r.id == 'up').name, 'up-renamed.jpg'); + expect(await countChildren('up'), 4, reason: 'in-place update keeps children'); + expect(await countChildren('stale'), 0, reason: 'collided row replaced and cascaded'); + }); + + test('a trashed stale row is replaced cleanly', () async { + await pragmaOn('trashed'); + await sut.updateUsersV1([_createUser()]); + await sut.updateAssetsV1([ + _createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg', deletedAt: DateTime(2024, 2, 1)), + ]); + + await sut.updateAssetsV1([_createAsset(id: 'fresh', checksum: 'AAA', fileName: 'photo.jpg')]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows.map((r) => r.id), ['fresh']); + expect(rows.single.deletedAt, isNull, reason: 'payload deletedAt wins through REPLACE'); + }); + + test('fields survive a cross-id replace exactly as sent', () async { + await pragmaOn('fields'); + await sut.updateUsersV1([_createUser()]); + await sut.updateAssetsV1([_createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg')]); + + await sut.updateAssetsV1([ + _createAsset( + id: 'fresh', + checksum: 'AAA', + fileName: 'rich.jpg', + isFavorite: true, + visibility: AssetVisibility.archive, + stackId: 'stack-1', + livePhotoVideoId: 'vid-1', + thumbhash: 'th-1', + width: 100, + height: 200, + fileCreatedAt: DateTime(2024, 5, 5), + fileModifiedAt: DateTime(2024, 6, 6), + ), + ]); + + final row = await (db.remoteAssetEntity.select()..where((t) => t.id.equals('fresh'))).getSingle(); + expect(row.name, 'rich.jpg'); + expect(row.isFavorite, isTrue); + expect(row.visibility, domain.AssetVisibility.archive); + expect(row.stackId, 'stack-1'); + expect(row.livePhotoVideoId, 'vid-1'); + expect(row.thumbHash, 'th-1'); + expect(row.width, 100); + expect(row.height, 200); + expect(row.createdAt, DateTime(2024, 5, 5)); + expect(row.updatedAt, DateTime(2024, 6, 6)); + }); + + test('emits a single INSERT OR REPLACE statement with an id DO UPDATE arm', () async { + final captured = []; + final originalDebugPrint = drift.driftRuntimeOptions.debugPrint; + drift.driftRuntimeOptions.debugPrint = captured.add; + addTearDown(() => drift.driftRuntimeOptions.debugPrint = originalDebugPrint); + final logDb = Drift( + drift.DatabaseConnection(NativeDatabase.memory(logStatements: true), closeStreamsSynchronously: true), + ); + addTearDown(logDb.close); + final logSut = SyncStreamRepository(logDb); + + await logSut.updateUsersV1([_createUser()]); + await logSut.updateAssetsV1([_createAsset(id: 'a', checksum: 'AAA', fileName: 'photo.jpg')]); + + final lines = captured.where((l) => l.contains('remote_asset_entity')).toList(); + // ignore: avoid_print + print('RECEIPT emitted=$lines'); + expect(lines, isNotEmpty); + expect(lines.join('\n'), contains('INSERT OR REPLACE INTO "remote_asset_entity"')); + expect(lines.join('\n'), contains('ON CONFLICT("id") DO UPDATE')); + }); + + test('updateUsersV1 still upserts users', () async { + await sut.updateUsersV1([_createUser()]); + await sut.updateUsersV1([_createUser(name: 'Renamed User')]); + + final rows = await db.userEntity.select().get(); expect(rows, hasLength(1)); - expect(rows.single.id, equals('last-id')); - expect(rows.single.libraryId, equals('lib-1')); + expect(rows.single.name, 'Renamed User'); }); }); } From 281d88c034c786325a4e60ba892deabab6842a90 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Wed, 5 Aug 2026 18:00:42 +0600 Subject: [PATCH 4/4] trim the dedupe tests to the essentials --- .../sync_stream_repository_test.dart | 294 +++++------------- 1 file changed, 72 insertions(+), 222 deletions(-) diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index 9111e376c2..13da161149 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -3,23 +3,17 @@ 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/domain/models/asset/base_asset.model.dart' as domain; -import 'package:immich_mobile/domain/models/memory.model.dart'; -import 'package:immich_mobile/infrastructure/entities/asset_face.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/remote_album.entity.drift.dart'; -import 'package:immich_mobile/infrastructure/entities/remote_album_asset.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'; -SyncUserV1 _createUser({String id = 'user-1', String name = 'Test User'}) { +SyncUserV1 _createUser({String id = 'user-1'}) { return SyncUserV1( id: id, - name: name, + name: 'Test User', email: 'test@test.com', deletedAt: null, avatarColor: const Optional.absent(), @@ -32,56 +26,42 @@ SyncAssetV1 _createAsset({ required String id, required String checksum, required String fileName, - String ownerId = 'user-1', int? width, int? height, String? libraryId, bool isFavorite = false, - AssetVisibility visibility = AssetVisibility.timeline, - String? stackId, - String? livePhotoVideoId, - String? thumbhash, - DateTime? deletedAt, - DateTime? fileCreatedAt, - DateTime? fileModifiedAt, }) { return SyncAssetV1( id: id, checksum: checksum, originalFileName: fileName, type: AssetTypeEnum.IMAGE, - ownerId: ownerId, + ownerId: 'user-1', isFavorite: isFavorite, - fileCreatedAt: fileCreatedAt ?? DateTime(2024, 1, 1), - fileModifiedAt: fileModifiedAt ?? DateTime(2024, 1, 1), + fileCreatedAt: DateTime(2024, 1, 1), + fileModifiedAt: DateTime(2024, 1, 1), createdAt: DateTime(2024, 1, 1), localDateTime: DateTime(2024, 1, 1), - visibility: visibility, + visibility: AssetVisibility.timeline, width: width, height: height, - deletedAt: deletedAt, + deletedAt: null, duration: null, libraryId: libraryId, - livePhotoVideoId: livePhotoVideoId, - stackId: stackId, - thumbhash: thumbhash, + livePhotoVideoId: null, + stackId: null, + thumbhash: null, isEdited: false, ); } -SyncAssetV2 _createAssetV2({ - required String id, - required String checksum, - required String fileName, - String ownerId = 'user-1', - String? libraryId, -}) { +SyncAssetV2 _createAssetV2({required String id, required String checksum, required String fileName}) { return SyncAssetV2( id: id, checksum: checksum, originalFileName: fileName, type: AssetTypeEnum.IMAGE, - ownerId: ownerId, + ownerId: 'user-1', isFavorite: false, fileCreatedAt: DateTime(2024, 1, 1), fileModifiedAt: DateTime(2024, 1, 1), @@ -92,7 +72,7 @@ SyncAssetV2 _createAssetV2({ height: null, deletedAt: null, duration: 0, - libraryId: libraryId, + libraryId: null, livePhotoVideoId: null, stackId: null, thumbhash: null, @@ -289,228 +269,98 @@ void main() { }); group('SyncStreamRepository - updateAssets upsert dedupe (#22522 #27186)', () { - Future pragmaOn(String testName) async { - await db.customStatement('PRAGMA foreign_keys = ON'); - final row = await db.customSelect('PRAGMA foreign_keys').getSingle(); - // ignore: avoid_print - print('RECEIPT test=$testName pragma foreign_keys=${row.read('foreign_keys')}'); + Future seedExif(String assetId) => + db.remoteExifEntity.insertOne(RemoteExifEntityCompanion.insert(assetId: assetId)); + + Future exifExists(String assetId) async { + final rows = await (db.remoteExifEntity.select()..where((t) => t.assetId.equals(assetId))).get(); + return rows.isNotEmpty; } - Future seedChildren(String assetId, String tag) async { - await db.remoteExifEntity.insertOne(RemoteExifEntityCompanion.insert(assetId: assetId)); - await db.remoteAlbumEntity.insertOne( - RemoteAlbumEntityCompanion.insert(id: 'album-$tag', name: 'Album', order: AlbumAssetOrder.desc), - ); - await db.remoteAlbumAssetEntity.insertOne( - RemoteAlbumAssetEntityCompanion.insert(assetId: assetId, albumId: 'album-$tag'), - ); - await db.memoryEntity.insertOne( - MemoryEntityCompanion.insert( - id: 'memory-$tag', - ownerId: 'user-1', - type: MemoryTypeEnum.onThisDay, - data: '{"year":2024}', - memoryAt: DateTime(2024, 1, 1), - ), - ); - await db.memoryAssetEntity.insertOne( - MemoryAssetEntityCompanion.insert(assetId: assetId, memoryId: 'memory-$tag'), - ); - await db.assetFaceEntity.insertOne( - AssetFaceEntityCompanion.insert( - id: 'face-$tag', - assetId: assetId, - imageWidth: 100, - imageHeight: 100, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 10, - boundingBoxY2: 10, - sourceType: 'ml', - ), - ); - } - - Future countChildren(String assetId) async { - final exif = await (db.remoteExifEntity.select()..where((t) => t.assetId.equals(assetId))).get(); - final albumAsset = await (db.remoteAlbumAssetEntity.select()..where((t) => t.assetId.equals(assetId))).get(); - final memoryAsset = await (db.memoryAssetEntity.select()..where((t) => t.assetId.equals(assetId))).get(); - final faces = await (db.assetFaceEntity.select()..where((t) => t.assetId.equals(assetId))).get(); - return exif.length + albumAsset.length + memoryAsset.length + faces.length; - } - - test('same-id update keeps children and updates fields', () async { - await pragmaOn('same-id'); + test('same-id update keeps the child row and updates fields', () async { await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([_createAsset(id: 'a', checksum: 'AAA', fileName: 'photo.jpg')]); - await seedChildren('a', 't1'); + final asset = _createAsset(id: 'a', checksum: 'AAA', fileName: 'photo.jpg'); + await sut.updateAssetsV1([asset]); + await seedExif(asset.id); - await sut.updateAssetsV1([_createAsset(id: 'a', checksum: 'AAA', fileName: 'renamed.jpg', isFavorite: true)]); + final renamed = _createAsset(id: asset.id, checksum: asset.checksum, fileName: 'renamed.jpg', isFavorite: true); + await sut.updateAssetsV1([renamed]); - expect(await countChildren('a'), 4, reason: 'DO UPDATE keeps the row, children survive'); - final row = await (db.remoteAssetEntity.select()..where((t) => t.id.equals('a'))).getSingle(); - expect(row.name, 'renamed.jpg'); + expect(await exifExists(asset.id), isTrue, reason: 'DO UPDATE keeps the row, the child survives'); + final row = await (db.remoteAssetEntity.select()..where((t) => t.id.equals(asset.id))).getSingle(); + expect(row.name, renamed.originalFileName); expect(row.isFavorite, isTrue); }); - test('reupload with a new id replaces the stale row and cascades its children', () async { - await pragmaOn('reupload'); + test('reupload with a new id replaces the stale row and cascades its child', () async { await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([_createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg')]); - await seedChildren('stale', 't2'); + final stale = _createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg'); + await sut.updateAssetsV1([stale]); + await seedExif(stale.id); - await sut.updateAssetsV1([_createAsset(id: 'fresh', checksum: 'AAA', fileName: 'photo.jpg')]); + final fresh = _createAsset(id: 'fresh', checksum: stale.checksum, fileName: stale.originalFileName); + await sut.updateAssetsV1([fresh]); final rows = await db.remoteAssetEntity.select().get(); - expect(rows.map((r) => r.id), ['fresh'], reason: 'no 2067, stale row replaced away'); - expect(await countChildren('stale'), 0, reason: 'stale children cascade with the replaced row'); + expect(rows, hasLength(1), reason: 'no 2067, stale row replaced away'); + expect(rows.single.id, fresh.id); + expect(await exifExists(stale.id), isFalse, reason: 'the stale child cascades with the replaced row'); // same scenario through V2 - await sut.updateAssetsV2([_createAssetV2(id: 'stale2', checksum: 'BBB', fileName: 'photo2.jpg')]); - await seedChildren('stale2', 't2b'); - await sut.updateAssetsV2([_createAssetV2(id: 'fresh2', checksum: 'BBB', fileName: 'photo2.jpg')]); + final staleV2 = _createAssetV2(id: 'stale2', checksum: 'BBB', fileName: 'photo2.jpg'); + await sut.updateAssetsV2([staleV2]); + await seedExif(staleV2.id); + final freshV2 = _createAssetV2(id: 'fresh2', checksum: staleV2.checksum, fileName: staleV2.originalFileName); + await sut.updateAssetsV2([freshV2]); final rows2 = await db.remoteAssetEntity.select().get(); - expect(rows2.map((r) => r.id), containsAllInOrder(['fresh', 'fresh2'])); - expect(await countChildren('stale2'), 0); + expect(rows2.map((r) => r.id), containsAll([fresh.id, freshV2.id])); + expect(await exifExists(staleV2.id), isFalse); }); test('library variant replaces only the matching library row', () async { - await pragmaOn('library'); await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([ - _createAsset(id: 'stale-lib', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), - _createAsset(id: 'keep-null', checksum: 'AAA', fileName: 'photo.jpg'), - ]); + final staleLib = _createAsset(id: 'stale-lib', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'); + final keepNull = _createAsset(id: 'keep-null', checksum: staleLib.checksum, fileName: staleLib.originalFileName); + await sut.updateAssetsV1([staleLib, keepNull]); - await sut.updateAssetsV1([ - _createAsset(id: 'fresh-lib', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'), - ]); + final freshLib = _createAsset( + id: 'fresh-lib', + checksum: staleLib.checksum, + fileName: staleLib.originalFileName, + libraryId: staleLib.libraryId, + ); + await sut.updateAssetsV1([freshLib]); final rows = await db.remoteAssetEntity.select().get(); expect(rows.map((r) => r.id).toSet(), { - 'fresh-lib', - 'keep-null', + freshLib.id, + keepNull.id, }, reason: 'library NULL and NOT NULL match different partial indexes'); }); test('batch-internal duplicates keep the last payload asset', () async { - await pragmaOn('batch-dupes'); await sut.updateUsersV1([_createUser()]); + final first = _createAsset(id: 'first-id', checksum: 'AAA', fileName: 'photo.jpg'); + final last = _createAsset(id: 'last-id', checksum: first.checksum, fileName: first.originalFileName); + final firstLib = _createAsset( + id: 'first-lib', + checksum: 'BBB', + fileName: first.originalFileName, + libraryId: 'lib-1', + ); + final lastLib = _createAsset( + id: 'last-lib', + checksum: firstLib.checksum, + fileName: firstLib.originalFileName, + libraryId: firstLib.libraryId, + ); - await sut.updateAssetsV1([ - _createAsset(id: 'first-id', checksum: 'AAA', fileName: 'photo.jpg'), - _createAsset(id: 'last-id', checksum: 'AAA', fileName: 'photo.jpg'), - _createAsset(id: 'first-lib', checksum: 'BBB', fileName: 'photo.jpg', libraryId: 'lib-1'), - _createAsset(id: 'last-lib', checksum: 'BBB', fileName: 'photo.jpg', libraryId: 'lib-1'), - ]); + await sut.updateAssetsV1([first, last, firstLib, lastLib]); final rows = await db.remoteAssetEntity.select().get(); expect(rows, hasLength(2), reason: 'REPLACE makes batch-internal duplicates last-wins, no crash'); - expect(rows.map((r) => r.id).toSet(), {'last-id', 'last-lib'}); - }); - - test('mixed batch of new, updated, and colliding assets all land', () async { - await pragmaOn('mixed'); - await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([ - _createAsset(id: 'up', checksum: 'UUU', fileName: 'up.jpg'), - _createAsset(id: 'stale', checksum: 'CCC', fileName: 'collide.jpg'), - ]); - await seedChildren('up', 't5a'); - await seedChildren('stale', 't5b'); - - await sut.updateAssetsV1([ - _createAsset(id: 'up', checksum: 'UUU', fileName: 'up-renamed.jpg'), - _createAsset(id: 'new', checksum: 'NNN', fileName: 'new.jpg'), - _createAsset(id: 'fresh', checksum: 'CCC', fileName: 'collide.jpg'), - ]); - - final rows = await db.remoteAssetEntity.select().get(); - expect(rows.map((r) => r.id).toSet(), {'up', 'new', 'fresh'}); - expect(rows.singleWhere((r) => r.id == 'up').name, 'up-renamed.jpg'); - expect(await countChildren('up'), 4, reason: 'in-place update keeps children'); - expect(await countChildren('stale'), 0, reason: 'collided row replaced and cascaded'); - }); - - test('a trashed stale row is replaced cleanly', () async { - await pragmaOn('trashed'); - await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([ - _createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg', deletedAt: DateTime(2024, 2, 1)), - ]); - - await sut.updateAssetsV1([_createAsset(id: 'fresh', checksum: 'AAA', fileName: 'photo.jpg')]); - - final rows = await db.remoteAssetEntity.select().get(); - expect(rows.map((r) => r.id), ['fresh']); - expect(rows.single.deletedAt, isNull, reason: 'payload deletedAt wins through REPLACE'); - }); - - test('fields survive a cross-id replace exactly as sent', () async { - await pragmaOn('fields'); - await sut.updateUsersV1([_createUser()]); - await sut.updateAssetsV1([_createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg')]); - - await sut.updateAssetsV1([ - _createAsset( - id: 'fresh', - checksum: 'AAA', - fileName: 'rich.jpg', - isFavorite: true, - visibility: AssetVisibility.archive, - stackId: 'stack-1', - livePhotoVideoId: 'vid-1', - thumbhash: 'th-1', - width: 100, - height: 200, - fileCreatedAt: DateTime(2024, 5, 5), - fileModifiedAt: DateTime(2024, 6, 6), - ), - ]); - - final row = await (db.remoteAssetEntity.select()..where((t) => t.id.equals('fresh'))).getSingle(); - expect(row.name, 'rich.jpg'); - expect(row.isFavorite, isTrue); - expect(row.visibility, domain.AssetVisibility.archive); - expect(row.stackId, 'stack-1'); - expect(row.livePhotoVideoId, 'vid-1'); - expect(row.thumbHash, 'th-1'); - expect(row.width, 100); - expect(row.height, 200); - expect(row.createdAt, DateTime(2024, 5, 5)); - expect(row.updatedAt, DateTime(2024, 6, 6)); - }); - - test('emits a single INSERT OR REPLACE statement with an id DO UPDATE arm', () async { - final captured = []; - final originalDebugPrint = drift.driftRuntimeOptions.debugPrint; - drift.driftRuntimeOptions.debugPrint = captured.add; - addTearDown(() => drift.driftRuntimeOptions.debugPrint = originalDebugPrint); - final logDb = Drift( - drift.DatabaseConnection(NativeDatabase.memory(logStatements: true), closeStreamsSynchronously: true), - ); - addTearDown(logDb.close); - final logSut = SyncStreamRepository(logDb); - - await logSut.updateUsersV1([_createUser()]); - await logSut.updateAssetsV1([_createAsset(id: 'a', checksum: 'AAA', fileName: 'photo.jpg')]); - - final lines = captured.where((l) => l.contains('remote_asset_entity')).toList(); - // ignore: avoid_print - print('RECEIPT emitted=$lines'); - expect(lines, isNotEmpty); - expect(lines.join('\n'), contains('INSERT OR REPLACE INTO "remote_asset_entity"')); - expect(lines.join('\n'), contains('ON CONFLICT("id") DO UPDATE')); - }); - - test('updateUsersV1 still upserts users', () async { - await sut.updateUsersV1([_createUser()]); - await sut.updateUsersV1([_createUser(name: 'Renamed User')]); - - final rows = await db.userEntity.select().get(); - expect(rows, hasLength(1)); - expect(rows.single.name, 'Renamed User'); + expect(rows.map((r) => r.id).toSet(), {last.id, lastLib.id}); }); }); }