diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 590e45f04b..238a19e5d1 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -466,6 +466,7 @@ Class | Method | HTTP request | Description - [SyncAckDto](doc//SyncAckDto.md) - [SyncAckSetDto](doc//SyncAckSetDto.md) - [SyncAlbumDeleteV1](doc//SyncAlbumDeleteV1.md) + - [SyncAlbumToAssetV1](doc//SyncAlbumToAssetV1.md) - [SyncAlbumUserDeleteV1](doc//SyncAlbumUserDeleteV1.md) - [SyncAlbumUserV1](doc//SyncAlbumUserV1.md) - [SyncAlbumV1](doc//SyncAlbumV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 573081503f..b9649b0ba3 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -249,6 +249,7 @@ part 'model/sync_ack_delete_dto.dart'; part 'model/sync_ack_dto.dart'; part 'model/sync_ack_set_dto.dart'; part 'model/sync_album_delete_v1.dart'; +part 'model/sync_album_to_asset_v1.dart'; part 'model/sync_album_user_delete_v1.dart'; part 'model/sync_album_user_v1.dart'; part 'model/sync_album_v1.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 28b67f52c5..dd37b628ab 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -554,6 +554,8 @@ class ApiClient { return SyncAckSetDto.fromJson(value); case 'SyncAlbumDeleteV1': return SyncAlbumDeleteV1.fromJson(value); + case 'SyncAlbumToAssetV1': + return SyncAlbumToAssetV1.fromJson(value); case 'SyncAlbumUserDeleteV1': return SyncAlbumUserDeleteV1.fromJson(value); case 'SyncAlbumUserV1': diff --git a/mobile/openapi/lib/model/sync_album_to_asset_v1.dart b/mobile/openapi/lib/model/sync_album_to_asset_v1.dart new file mode 100644 index 0000000000..6908f320f8 --- /dev/null +++ b/mobile/openapi/lib/model/sync_album_to_asset_v1.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAlbumToAssetV1 { + /// Returns a new [SyncAlbumToAssetV1] instance. + SyncAlbumToAssetV1({ + required this.albumId, + required this.assetId, + }); + + String albumId; + + String assetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAlbumToAssetV1 && + other.albumId == albumId && + other.assetId == assetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumId.hashCode) + + (assetId.hashCode); + + @override + String toString() => 'SyncAlbumToAssetV1[albumId=$albumId, assetId=$assetId]'; + + Map toJson() { + final json = {}; + json[r'albumId'] = this.albumId; + json[r'assetId'] = this.assetId; + return json; + } + + /// Returns a new [SyncAlbumToAssetV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAlbumToAssetV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAlbumToAssetV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAlbumToAssetV1( + albumId: mapValueOfType(json, r'albumId')!, + assetId: mapValueOfType(json, r'assetId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAlbumToAssetV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAlbumToAssetV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAlbumToAssetV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAlbumToAssetV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumId', + 'assetId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 654ff45d6f..7129ebc0a4 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -40,6 +40,13 @@ class SyncEntityType { static const albumUserV1 = SyncEntityType._(r'AlbumUserV1'); static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1'); static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1'); + static const albumAssetV1 = SyncEntityType._(r'AlbumAssetV1'); + static const albumAssetBackfillV1 = SyncEntityType._(r'AlbumAssetBackfillV1'); + static const albumAssetExifV1 = SyncEntityType._(r'AlbumAssetExifV1'); + static const albumAssetExifBackfillV1 = SyncEntityType._(r'AlbumAssetExifBackfillV1'); + static const albumToAssetV1 = SyncEntityType._(r'AlbumToAssetV1'); + static const albumToAssetDeleteV1 = SyncEntityType._(r'AlbumToAssetDeleteV1'); + static const albumToAssetBackfillV1 = SyncEntityType._(r'AlbumToAssetBackfillV1'); static const syncAckV1 = SyncEntityType._(r'SyncAckV1'); /// List of all possible values in this [enum][SyncEntityType]. @@ -61,6 +68,13 @@ class SyncEntityType { albumUserV1, albumUserBackfillV1, albumUserDeleteV1, + albumAssetV1, + albumAssetBackfillV1, + albumAssetExifV1, + albumAssetExifBackfillV1, + albumToAssetV1, + albumToAssetDeleteV1, + albumToAssetBackfillV1, syncAckV1, ]; @@ -117,6 +131,13 @@ class SyncEntityTypeTypeTransformer { case r'AlbumUserV1': return SyncEntityType.albumUserV1; case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1; case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1; + case r'AlbumAssetV1': return SyncEntityType.albumAssetV1; + case r'AlbumAssetBackfillV1': return SyncEntityType.albumAssetBackfillV1; + case r'AlbumAssetExifV1': return SyncEntityType.albumAssetExifV1; + case r'AlbumAssetExifBackfillV1': return SyncEntityType.albumAssetExifBackfillV1; + case r'AlbumToAssetV1': return SyncEntityType.albumToAssetV1; + case r'AlbumToAssetDeleteV1': return SyncEntityType.albumToAssetDeleteV1; + case r'AlbumToAssetBackfillV1': return SyncEntityType.albumToAssetBackfillV1; case r'SyncAckV1': return SyncEntityType.syncAckV1; default: if (!allowNull) { diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index c149c329de..c3cce99b1a 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -31,6 +31,9 @@ class SyncRequestType { static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1'); static const albumsV1 = SyncRequestType._(r'AlbumsV1'); static const albumUsersV1 = SyncRequestType._(r'AlbumUsersV1'); + static const albumToAssetsV1 = SyncRequestType._(r'AlbumToAssetsV1'); + static const albumAssetsV1 = SyncRequestType._(r'AlbumAssetsV1'); + static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1'); /// List of all possible values in this [enum][SyncRequestType]. static const values = [ @@ -42,6 +45,9 @@ class SyncRequestType { partnerAssetExifsV1, albumsV1, albumUsersV1, + albumToAssetsV1, + albumAssetsV1, + albumAssetExifsV1, ]; static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value); @@ -88,6 +94,9 @@ class SyncRequestTypeTypeTransformer { case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1; case r'AlbumsV1': return SyncRequestType.albumsV1; case r'AlbumUsersV1': return SyncRequestType.albumUsersV1; + case r'AlbumToAssetsV1': return SyncRequestType.albumToAssetsV1; + case r'AlbumAssetsV1': return SyncRequestType.albumAssetsV1; + case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c436f81953..b63b50338b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13435,6 +13435,10 @@ ], "type": "object" }, + "SyncAckV1": { + "properties": {}, + "type": "object" + }, "SyncAlbumDeleteV1": { "properties": { "albumId": { @@ -13446,6 +13450,21 @@ ], "type": "object" }, + "SyncAlbumToAssetV1": { + "properties": { + "albumId": { + "type": "string" + }, + "assetId": { + "type": "string" + } + }, + "required": [ + "albumId", + "assetId" + ], + "type": "object" + }, "SyncAlbumUserDeleteV1": { "properties": { "albumId": { @@ -13774,6 +13793,13 @@ "AlbumUserV1", "AlbumUserBackfillV1", "AlbumUserDeleteV1", + "AlbumAssetV1", + "AlbumAssetBackfillV1", + "AlbumAssetExifV1", + "AlbumAssetExifBackfillV1", + "AlbumToAssetV1", + "AlbumToAssetDeleteV1", + "AlbumToAssetBackfillV1", "SyncAckV1" ], "type": "string" @@ -13821,7 +13847,10 @@ "PartnerAssetsV1", "PartnerAssetExifsV1", "AlbumsV1", - "AlbumUsersV1" + "AlbumUsersV1", + "AlbumToAssetsV1", + "AlbumAssetsV1", + "AlbumAssetExifsV1" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index ff8ea67a18..5b94834105 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4078,6 +4078,13 @@ export enum SyncEntityType { AlbumUserV1 = "AlbumUserV1", AlbumUserBackfillV1 = "AlbumUserBackfillV1", AlbumUserDeleteV1 = "AlbumUserDeleteV1", + AlbumAssetV1 = "AlbumAssetV1", + AlbumAssetBackfillV1 = "AlbumAssetBackfillV1", + AlbumAssetExifV1 = "AlbumAssetExifV1", + AlbumAssetExifBackfillV1 = "AlbumAssetExifBackfillV1", + AlbumToAssetV1 = "AlbumToAssetV1", + AlbumToAssetDeleteV1 = "AlbumToAssetDeleteV1", + AlbumToAssetBackfillV1 = "AlbumToAssetBackfillV1", SyncAckV1 = "SyncAckV1" } export enum SyncRequestType { @@ -4088,7 +4095,10 @@ export enum SyncRequestType { PartnerAssetsV1 = "PartnerAssetsV1", PartnerAssetExifsV1 = "PartnerAssetExifsV1", AlbumsV1 = "AlbumsV1", - AlbumUsersV1 = "AlbumUsersV1" + AlbumUsersV1 = "AlbumUsersV1", + AlbumToAssetsV1 = "AlbumToAssetsV1", + AlbumAssetsV1 = "AlbumAssetsV1", + AlbumAssetExifsV1 = "AlbumAssetExifsV1" } export enum TranscodeHWAccel { Nvenc = "nvenc", diff --git a/server/src/database.ts b/server/src/database.ts index 1cddda1ee6..8a3c3b702b 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -340,27 +340,21 @@ export const columns = { apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'], syncAsset: [ - 'id', - 'ownerId', - 'originalFileName', - 'thumbhash', - 'checksum', - 'fileCreatedAt', - 'fileModifiedAt', - 'localDateTime', - 'type', - 'deletedAt', - 'isFavorite', - 'visibility', - 'updateId', - 'duration', - ], - syncAlbumUser: [ - 'albums_shared_users_users.albumsId as albumId', - 'albums_shared_users_users.usersId as userId', - 'albums_shared_users_users.role', - 'albums_shared_users_users.updateId', + 'assets.id', + 'assets.ownerId', + 'assets.originalFileName', + 'assets.thumbhash', + 'assets.checksum', + 'assets.fileCreatedAt', + 'assets.fileModifiedAt', + 'assets.localDateTime', + 'assets.type', + 'assets.deletedAt', + 'assets.isFavorite', + 'assets.visibility', + 'assets.duration', ], + syncAlbumUser: ['album_users.albumsId as albumId', 'album_users.usersId as userId', 'album_users.role'], stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'], syncAssetExif: [ 'exif.assetId', @@ -388,7 +382,6 @@ export const columns = { 'exif.profileDescription', 'exif.rating', 'exif.fps', - 'exif.updateId', ], exif: [ 'exif.assetId', diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 7a4c319d0b..3fcf4ddea7 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -92,6 +92,15 @@ export interface AlbumsAssetsAssets { albumsId: string; assetsId: string; createdAt: Generated; + updatedAt: Generated; + updateId: Generated; +} + +export interface AlbumAssetsAudit { + deletedAt: Generated; + id: Generated; + albumId: string; + assetId: string; } export interface AlbumsSharedUsersUsers { @@ -487,6 +496,7 @@ export interface DB { albums: Albums; albums_audit: AlbumsAudit; albums_assets_assets: AlbumsAssetsAssets; + album_assets_audit: AlbumAssetsAudit; albums_shared_users_users: AlbumsSharedUsersUsers; album_users_audit: AlbumUsersAudit; api_keys: ApiKeys; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 91c93fef66..b552f52a31 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -145,6 +145,18 @@ export class SyncAlbumV1 { order!: AssetOrder; } +export class SyncAlbumToAssetV1 { + albumId!: string; + assetId!: string; +} + +export class SyncAlbumToAssetDeleteV1 { + albumId!: string; + assetId!: string; +} + +export class SyncAckV1 {} + export type SyncItem = { [SyncEntityType.UserV1]: SyncUserV1; [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; @@ -163,7 +175,14 @@ export type SyncItem = { [SyncEntityType.AlbumUserV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1; - [SyncEntityType.SyncAckV1]: object; + [SyncEntityType.AlbumAssetV1]: SyncAssetV1; + [SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1; + [SyncEntityType.AlbumAssetExifV1]: SyncAssetExifV1; + [SyncEntityType.AlbumAssetExifBackfillV1]: SyncAssetExifV1; + [SyncEntityType.AlbumToAssetV1]: SyncAlbumToAssetV1; + [SyncEntityType.AlbumToAssetBackfillV1]: SyncAlbumToAssetV1; + [SyncEntityType.AlbumToAssetDeleteV1]: SyncAlbumToAssetDeleteV1; + [SyncEntityType.SyncAckV1]: SyncAckV1; }; const responseDtos = [ @@ -178,6 +197,8 @@ const responseDtos = [ SyncAlbumDeleteV1, SyncAlbumUserV1, SyncAlbumUserDeleteV1, + SyncAlbumToAssetV1, + SyncAckV1, ]; export const extraSyncModels = responseDtos; diff --git a/server/src/enum.ts b/server/src/enum.ts index 4f3fd9a521..bbe8f001de 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -581,6 +581,9 @@ export enum SyncRequestType { PartnerAssetExifsV1 = 'PartnerAssetExifsV1', AlbumsV1 = 'AlbumsV1', AlbumUsersV1 = 'AlbumUsersV1', + AlbumToAssetsV1 = 'AlbumToAssetsV1', + AlbumAssetsV1 = 'AlbumAssetsV1', + AlbumAssetExifsV1 = 'AlbumAssetExifsV1', } export enum SyncEntityType { @@ -605,6 +608,13 @@ export enum SyncEntityType { AlbumUserV1 = 'AlbumUserV1', AlbumUserBackfillV1 = 'AlbumUserBackfillV1', AlbumUserDeleteV1 = 'AlbumUserDeleteV1', + AlbumAssetV1 = 'AlbumAssetV1', + AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1', + AlbumAssetExifV1 = 'AlbumAssetExifV1', + AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1', + AlbumToAssetV1 = 'AlbumToAssetV1', + AlbumToAssetDeleteV1 = 'AlbumToAssetDeleteV1', + AlbumToAssetBackfillV1 = 'AlbumToAssetBackfillV1', SyncAckV1 = 'SyncAckV1', } diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 26aaf014d9..46b72ffca0 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -74,20 +74,20 @@ order by -- SyncRepository.getAssetUpserts select - "id", - "ownerId", - "originalFileName", - "thumbhash", - "checksum", - "fileCreatedAt", - "fileModifiedAt", - "localDateTime", - "type", - "deletedAt", - "isFavorite", - "visibility", - "updateId", - "duration" + "assets"."id", + "assets"."ownerId", + "assets"."originalFileName", + "assets"."thumbhash", + "assets"."checksum", + "assets"."fileCreatedAt", + "assets"."fileModifiedAt", + "assets"."localDateTime", + "assets"."type", + "assets"."deletedAt", + "assets"."isFavorite", + "assets"."visibility", + "assets"."duration", + "assets"."updateId" from "assets" where @@ -111,20 +111,20 @@ order by -- SyncRepository.getPartnerAssetsBackfill select - "id", - "ownerId", - "originalFileName", - "thumbhash", - "checksum", - "fileCreatedAt", - "fileModifiedAt", - "localDateTime", - "type", - "deletedAt", - "isFavorite", - "visibility", - "updateId", - "duration" + "assets"."id", + "assets"."ownerId", + "assets"."originalFileName", + "assets"."thumbhash", + "assets"."checksum", + "assets"."fileCreatedAt", + "assets"."fileModifiedAt", + "assets"."localDateTime", + "assets"."type", + "assets"."deletedAt", + "assets"."isFavorite", + "assets"."visibility", + "assets"."duration", + "assets"."updateId" from "assets" where @@ -137,20 +137,20 @@ order by -- SyncRepository.getPartnerAssetsUpserts select - "id", - "ownerId", - "originalFileName", - "thumbhash", - "checksum", - "fileCreatedAt", - "fileModifiedAt", - "localDateTime", - "type", - "deletedAt", - "isFavorite", - "visibility", - "updateId", - "duration" + "assets"."id", + "assets"."ownerId", + "assets"."originalFileName", + "assets"."thumbhash", + "assets"."checksum", + "assets"."fileCreatedAt", + "assets"."fileModifiedAt", + "assets"."localDateTime", + "assets"."type", + "assets"."deletedAt", + "assets"."isFavorite", + "assets"."visibility", + "assets"."duration", + "assets"."updateId" from "assets" where @@ -365,6 +365,35 @@ where order by "albums"."updateId" asc +-- SyncRepository.getAlbumToAssetDeletes +select + "id", + "assetId", + "albumId" +from + "album_assets_audit" +where + "albumId" in ( + select + "id" + from + "albums" + where + "ownerId" = $1 + union + ( + select + "albumUsers"."albumsId" as "id" + from + "albums_shared_users_users" as "albumUsers" + where + "albumUsers"."usersId" = $2 + ) + ) + and "deletedAt" < now() - interval '1 millisecond' +order by + "id" asc + -- SyncRepository.getAlbumUserDeletes select "id", @@ -409,12 +438,12 @@ order by -- SyncRepository.getAlbumUsersBackfill select - "albums_shared_users_users"."albumsId" as "albumId", - "albums_shared_users_users"."usersId" as "userId", - "albums_shared_users_users"."role", - "albums_shared_users_users"."updateId" + "album_users"."albumsId" as "albumId", + "album_users"."usersId" as "userId", + "album_users"."role", + "album_users"."updateId" from - "albums_shared_users_users" + "albums_shared_users_users" as "album_users" where "albumsId" = $1 and "updatedAt" < now() - interval '1 millisecond' @@ -425,15 +454,15 @@ order by -- SyncRepository.getAlbumUserUpserts select - "albums_shared_users_users"."albumsId" as "albumId", - "albums_shared_users_users"."usersId" as "userId", - "albums_shared_users_users"."role", - "albums_shared_users_users"."updateId" + "album_users"."albumsId" as "albumId", + "album_users"."usersId" as "userId", + "album_users"."role", + "album_users"."updateId" from - "albums_shared_users_users" + "albums_shared_users_users" as "album_users" where - "albums_shared_users_users"."updatedAt" < now() - interval '1 millisecond' - and "albums_shared_users_users"."albumsId" in ( + "album_users"."updatedAt" < now() - interval '1 millisecond' + and "album_users"."albumsId" in ( select "id" from @@ -451,4 +480,175 @@ where ) ) order by - "albums_shared_users_users"."updateId" asc + "album_users"."updateId" asc + +-- SyncRepository.getAlbumAssetsBackfill +select + "assets"."id", + "assets"."ownerId", + "assets"."originalFileName", + "assets"."thumbhash", + "assets"."checksum", + "assets"."fileCreatedAt", + "assets"."fileModifiedAt", + "assets"."localDateTime", + "assets"."type", + "assets"."deletedAt", + "assets"."isFavorite", + "assets"."visibility", + "assets"."duration", + "assets"."updateId" +from + "assets" + inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "assets"."id" +where + "album_assets"."albumsId" = $1 + and "assets"."updatedAt" < now() - interval '1 millisecond' + and "assets"."updateId" <= $2 + and "assets"."updateId" >= $3 +order by + "assets"."updateId" asc + +-- SyncRepository.getAlbumAssetsUpserts +select + "assets"."id", + "assets"."ownerId", + "assets"."originalFileName", + "assets"."thumbhash", + "assets"."checksum", + "assets"."fileCreatedAt", + "assets"."fileModifiedAt", + "assets"."localDateTime", + "assets"."type", + "assets"."deletedAt", + "assets"."isFavorite", + "assets"."visibility", + "assets"."duration", + "assets"."updateId" +from + "assets" + inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "assets"."id" + inner join "albums" on "albums"."id" = "album_assets"."albumsId" + left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "album_assets"."albumsId" +where + "assets"."updatedAt" < now() - interval '1 millisecond' + and ( + "albums"."ownerId" = $1 + or "album_users"."usersId" = $2 + ) +order by + "assets"."updateId" asc + +-- SyncRepository.getAlbumToAssetBackfill +select + "album_assets"."assetsId" as "assetId", + "album_assets"."albumsId" as "albumId", + "album_assets"."updateId" +from + "albums_assets_assets" as "album_assets" +where + "album_assets"."albumsId" = $1 + and "album_assets"."updatedAt" < now() - interval '1 millisecond' + and "album_assets"."updateId" <= $2 + and "album_assets"."updateId" >= $3 +order by + "album_assets"."updateId" asc + +-- SyncRepository.getAlbumToAssetUpserts +select + "album_assets"."assetsId" as "assetId", + "album_assets"."albumsId" as "albumId", + "album_assets"."updateId" +from + "albums_assets_assets" as "album_assets" + inner join "albums" on "albums"."id" = "album_assets"."albumsId" + left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "album_assets"."albumsId" +where + "album_assets"."updatedAt" < now() - interval '1 millisecond' + and ( + "albums"."ownerId" = $1 + or "album_users"."usersId" = $2 + ) +order by + "album_assets"."updateId" asc + +-- SyncRepository.getAlbumAssetExifsBackfill +select + "exif"."assetId", + "exif"."description", + "exif"."exifImageWidth", + "exif"."exifImageHeight", + "exif"."fileSizeInByte", + "exif"."orientation", + "exif"."dateTimeOriginal", + "exif"."modifyDate", + "exif"."timeZone", + "exif"."latitude", + "exif"."longitude", + "exif"."projectionType", + "exif"."city", + "exif"."state", + "exif"."country", + "exif"."make", + "exif"."model", + "exif"."lensModel", + "exif"."fNumber", + "exif"."focalLength", + "exif"."iso", + "exif"."exposureTime", + "exif"."profileDescription", + "exif"."rating", + "exif"."fps", + "exif"."updateId" +from + "exif" + inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "exif"."assetId" +where + "album_assets"."albumsId" = $1 + and "exif"."updatedAt" < now() - interval '1 millisecond' + and "exif"."updateId" <= $2 + and "exif"."updateId" >= $3 +order by + "exif"."updateId" asc + +-- SyncRepository.getAlbumAssetExifsUpserts +select + "exif"."assetId", + "exif"."description", + "exif"."exifImageWidth", + "exif"."exifImageHeight", + "exif"."fileSizeInByte", + "exif"."orientation", + "exif"."dateTimeOriginal", + "exif"."modifyDate", + "exif"."timeZone", + "exif"."latitude", + "exif"."longitude", + "exif"."projectionType", + "exif"."city", + "exif"."state", + "exif"."country", + "exif"."make", + "exif"."model", + "exif"."lensModel", + "exif"."fNumber", + "exif"."focalLength", + "exif"."iso", + "exif"."exposureTime", + "exif"."profileDescription", + "exif"."rating", + "exif"."fps", + "exif"."updateId" +from + "exif" + inner join "albums_assets_assets" as "album_assets" on "album_assets"."assetsId" = "exif"."assetId" + inner join "albums" on "albums"."id" = "album_assets"."albumsId" + left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "album_assets"."albumsId" +where + "exif"."updatedAt" < now() - interval '1 millisecond' + and ( + "albums"."ownerId" = $1 + or "album_users"."usersId" = $2 + ) +order by + "exif"."updateId" asc diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 341966c6fa..0d44af4bb2 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -7,7 +7,13 @@ import { DummyValue, GenerateSql } from 'src/decorators'; import { SyncEntityType } from 'src/enum'; import { SyncAck } from 'src/types'; -type AuditTables = 'users_audit' | 'partners_audit' | 'assets_audit' | 'albums_audit' | 'album_users_audit'; +type AuditTables = + | 'users_audit' + | 'partners_audit' + | 'assets_audit' + | 'albums_audit' + | 'album_users_audit' + | 'album_assets_audit'; type UpsertTables = 'users' | 'partners' | 'assets' | 'exif' | 'albums' | 'albums_shared_users_users'; @Injectable() @@ -87,6 +93,7 @@ export class SyncRepository { return this.db .selectFrom('assets') .select(columns.syncAsset) + .select('assets.updateId') .where('ownerId', '=', userId) .$call((qb) => this.upsertTableFilters(qb, ack)) .stream(); @@ -109,6 +116,7 @@ export class SyncRepository { return this.db .selectFrom('assets') .select(columns.syncAsset) + .select('assets.updateId') .where('ownerId', '=', partnerId) .where('updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) .where('updateId', '<=', beforeUpdateId) @@ -122,6 +130,7 @@ export class SyncRepository { return this.db .selectFrom('assets') .select(columns.syncAsset) + .select('assets.updateId') .where('ownerId', 'in', (eb) => eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId), ) @@ -156,6 +165,7 @@ export class SyncRepository { return this.db .selectFrom('exif') .select(columns.syncAssetExif) + .select('exif.updateId') .where('assetId', 'in', (eb) => eb.selectFrom('assets').select('id').where('ownerId', '=', userId)) .$call((qb) => this.upsertTableFilters(qb, ack)) .stream(); @@ -166,6 +176,7 @@ export class SyncRepository { return this.db .selectFrom('exif') .select(columns.syncAssetExif) + .select('exif.updateId') .innerJoin('assets', 'assets.id', 'exif.assetId') .where('assets.ownerId', '=', partnerId) .where('exif.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) @@ -180,6 +191,7 @@ export class SyncRepository { return this.db .selectFrom('exif') .select(columns.syncAssetExif) + .select('exif.updateId') .where('assetId', 'in', (eb) => eb .selectFrom('assets') @@ -227,6 +239,33 @@ export class SyncRepository { .stream(); } + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getAlbumToAssetDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('album_assets_audit') + .select(['id', 'assetId', 'albumId']) + .where((eb) => + eb( + 'albumId', + 'in', + eb + .selectFrom('albums') + .select(['id']) + .where('ownerId', '=', userId) + .union((eb) => + eb.parens( + eb + .selectFrom('albums_shared_users_users as albumUsers') + .select(['albumUsers.albumsId as id']) + .where('albumUsers.usersId', '=', userId), + ), + ), + ), + ) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + @GenerateSql({ params: [DummyValue.UUID], stream: true }) getAlbumUserDeletes(userId: string, ack?: SyncAck) { return this.db @@ -266,11 +305,12 @@ export class SyncRepository { .execute(); } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true }) + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true }) getAlbumUsersBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) { return this.db - .selectFrom('albums_shared_users_users') + .selectFrom('albums_shared_users_users as album_users') .select(columns.syncAlbumUser) + .select('album_users.updateId') .where('albumsId', '=', albumId) .where('updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) .where('updateId', '<=', beforeUpdateId) @@ -282,14 +322,15 @@ export class SyncRepository { @GenerateSql({ params: [DummyValue.UUID], stream: true }) getAlbumUserUpserts(userId: string, ack?: SyncAck) { return this.db - .selectFrom('albums_shared_users_users') + .selectFrom('albums_shared_users_users as album_users') .select(columns.syncAlbumUser) - .where('albums_shared_users_users.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) - .$if(!!ack, (qb) => qb.where('albums_shared_users_users.updateId', '>', ack!.updateId)) - .orderBy('albums_shared_users_users.updateId', 'asc') + .select('album_users.updateId') + .where('album_users.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('album_users.updateId', '>', ack!.updateId)) + .orderBy('album_users.updateId', 'asc') .where((eb) => eb( - 'albums_shared_users_users.albumsId', + 'album_users.albumsId', 'in', eb .selectFrom('albums') @@ -308,6 +349,95 @@ export class SyncRepository { .stream(); } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true }) + getAlbumAssetsBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) { + return this.db + .selectFrom('assets') + .innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'assets.id') + .select(columns.syncAsset) + .select('assets.updateId') + .where('album_assets.albumsId', '=', albumId) + .where('assets.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .where('assets.updateId', '<=', beforeUpdateId) + .$if(!!afterUpdateId, (eb) => eb.where('assets.updateId', '>=', afterUpdateId!)) + .orderBy('assets.updateId', 'asc') + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getAlbumAssetsUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('assets') + .innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'assets.id') + .select(columns.syncAsset) + .select('assets.updateId') + .where('assets.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('assets.updateId', '>', ack!.updateId)) + .orderBy('assets.updateId', 'asc') + .innerJoin('albums', 'albums.id', 'album_assets.albumsId') + .leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'album_assets.albumsId') + .where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)])) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true }) + getAlbumToAssetBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) { + return this.db + .selectFrom('albums_assets_assets as album_assets') + .select(['album_assets.assetsId as assetId', 'album_assets.albumsId as albumId', 'album_assets.updateId']) + .where('album_assets.albumsId', '=', albumId) + .where('album_assets.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .where('album_assets.updateId', '<=', beforeUpdateId) + .$if(!!afterUpdateId, (eb) => eb.where('album_assets.updateId', '>=', afterUpdateId!)) + .orderBy('album_assets.updateId', 'asc') + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getAlbumToAssetUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('albums_assets_assets as album_assets') + .select(['album_assets.assetsId as assetId', 'album_assets.albumsId as albumId', 'album_assets.updateId']) + .where('album_assets.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('album_assets.updateId', '>', ack!.updateId)) + .orderBy('album_assets.updateId', 'asc') + .innerJoin('albums', 'albums.id', 'album_assets.albumsId') + .leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'album_assets.albumsId') + .where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)])) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true }) + getAlbumAssetExifsBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) { + return this.db + .selectFrom('exif') + .innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'exif.assetId') + .select(columns.syncAssetExif) + .select('exif.updateId') + .where('album_assets.albumsId', '=', albumId) + .where('exif.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .where('exif.updateId', '<=', beforeUpdateId) + .$if(!!afterUpdateId, (eb) => eb.where('exif.updateId', '>=', afterUpdateId!)) + .orderBy('exif.updateId', 'asc') + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getAlbumAssetExifsUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('exif') + .innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'exif.assetId') + .select(columns.syncAssetExif) + .select('exif.updateId') + .where('exif.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('exif.updateId', '>', ack!.updateId)) + .orderBy('exif.updateId', 'asc') + .innerJoin('albums', 'albums.id', 'album_assets.albumsId') + .leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'album_assets.albumsId') + .where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)])) + .stream(); + } + private auditTableFilters, D>(qb: SelectQueryBuilder, ack?: SyncAck) { const builder = qb as SelectQueryBuilder; return builder diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index a03f715bff..fdef5867b9 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -142,6 +142,20 @@ export const albums_delete_audit = registerFunction({ synchronize: false, }); +export const album_assets_delete_audit = registerFunction({ + name: 'album_assets_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO album_assets_audit ("albumId", "assetId") + SELECT "albumsId", "assetsId" FROM OLD + WHERE "albumsId" IN (SELECT "id" FROM albums WHERE "id" IN (SELECT "albumsId" FROM OLD)); + RETURN NULL; + END`, + synchronize: false, +}); + export const album_users_delete_audit = registerFunction({ name: 'album_users_delete_audit', returnType: 'TRIGGER', diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index d2f8d80afc..bab3acb232 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -13,6 +13,7 @@ import { users_delete_audit, } from 'src/schema/functions'; import { ActivityTable } from 'src/schema/tables/activity.table'; +import { AlbumAssetAuditTable } from 'src/schema/tables/album-asset-audit.table'; import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; import { AlbumAuditTable } from 'src/schema/tables/album-audit.table'; import { AlbumUserAuditTable } from 'src/schema/tables/album-user-audit.table'; @@ -58,6 +59,7 @@ export class ImmichDatabase { tables = [ ActivityTable, AlbumAssetTable, + AlbumAssetAuditTable, AlbumAuditTable, AlbumUserAuditTable, AlbumUserTable, diff --git a/server/src/schema/migrations/1750676477029-AlbumAssetUpdateId.ts b/server/src/schema/migrations/1750676477029-AlbumAssetUpdateId.ts new file mode 100644 index 0000000000..8feba6f11c --- /dev/null +++ b/server/src/schema/migrations/1750676477029-AlbumAssetUpdateId.ts @@ -0,0 +1,18 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "albums_assets_assets" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`ALTER TABLE "albums_assets_assets" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); + await sql`CREATE INDEX "IDX_album_assets_update_id" ON "albums_assets_assets" ("updateId")`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "album_assets_updated_at" + BEFORE UPDATE ON "albums_assets_assets" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "IDX_album_assets_update_id";`.execute(db); + await sql`ALTER TABLE "albums_assets_assets" DROP COLUMN "updatedAt";`.execute(db); + await sql`ALTER TABLE "albums_assets_assets" DROP COLUMN "updateId";`.execute(db); + await sql`DROP TRIGGER "album_assets_updated_at" ON "albums_assets_assets";`.execute(db); +} diff --git a/server/src/schema/migrations/1750694237564-AlbumAssetAuditTable.ts b/server/src/schema/migrations/1750694237564-AlbumAssetAuditTable.ts new file mode 100644 index 0000000000..0191b5d07a --- /dev/null +++ b/server/src/schema/migrations/1750694237564-AlbumAssetAuditTable.ts @@ -0,0 +1,22 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "album_assets_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "albumId" uuid NOT NULL, "assetId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db); + await sql`ALTER TABLE "album_assets_audit" ADD CONSTRAINT "PK_32969b576ec8f78d84f37c2eb2d" PRIMARY KEY ("id");`.execute(db); + await sql`CREATE INDEX "IDX_album_assets_audit_album_id" ON "album_assets_audit" ("albumId")`.execute(db); + await sql`CREATE INDEX "IDX_album_assets_audit_asset_id" ON "album_assets_audit" ("assetId")`.execute(db); + await sql`CREATE INDEX "IDX_album_assets_audit_deleted_at" ON "album_assets_audit" ("deletedAt")`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "album_assets_updated_at" + BEFORE UPDATE ON "albums_assets_assets" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "album_assets_updated_at" ON "albums_assets_assets";`.execute(db); + await sql`DROP INDEX "IDX_album_assets_audit_album_id";`.execute(db); + await sql`DROP INDEX "IDX_album_assets_audit_asset_id";`.execute(db); + await sql`DROP INDEX "IDX_album_assets_audit_deleted_at";`.execute(db); + await sql`ALTER TABLE "album_assets_audit" DROP CONSTRAINT "PK_32969b576ec8f78d84f37c2eb2d";`.execute(db); + await sql`DROP TABLE "album_assets_audit";`.execute(db); +} diff --git a/server/src/schema/migrations/1750780093818-AddAlbumToAssetDeleteTrigger.ts b/server/src/schema/migrations/1750780093818-AddAlbumToAssetDeleteTrigger.ts new file mode 100644 index 0000000000..f3cb75d10e --- /dev/null +++ b/server/src/schema/migrations/1750780093818-AddAlbumToAssetDeleteTrigger.ts @@ -0,0 +1,28 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION album_assets_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO album_assets_audit ("albumId", "assetId") + SELECT "albumsId", "assetsId" FROM OLD + WHERE "albumsId" IN (SELECT "id" FROM albums WHERE "id" IN (SELECT "albumsId" FROM OLD)); + RETURN NULL; + END + $$;`.execute(db); + await sql`ALTER TABLE "album_assets_audit" ADD CONSTRAINT "FK_8047b44b812619a3c75a2839b0d" FOREIGN KEY ("albumId") REFERENCES "albums" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "album_assets_delete_audit" + AFTER DELETE ON "albums_assets_assets" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() <= 1) + EXECUTE FUNCTION album_assets_delete_audit();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "album_assets_delete_audit" ON "albums_assets_assets";`.execute(db); + await sql`ALTER TABLE "album_assets_audit" DROP CONSTRAINT "FK_8047b44b812619a3c75a2839b0d";`.execute(db); + await sql`DROP FUNCTION album_assets_delete_audit;`.execute(db); +} diff --git a/server/src/schema/tables/album-asset-audit.table.ts b/server/src/schema/tables/album-asset-audit.table.ts new file mode 100644 index 0000000000..d2b71aa599 --- /dev/null +++ b/server/src/schema/tables/album-asset-audit.table.ts @@ -0,0 +1,23 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { Column, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools'; + +@Table('album_assets_audit') +export class AlbumAssetAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: string; + + @ForeignKeyColumn(() => AlbumTable, { + type: 'uuid', + indexName: 'IDX_album_assets_audit_album_id', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + albumId!: string; + + @Column({ type: 'uuid', indexName: 'IDX_album_assets_audit_asset_id' }) + assetId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_album_assets_audit_deleted_at' }) + deletedAt!: Date; +} diff --git a/server/src/schema/tables/album-asset.table.ts b/server/src/schema/tables/album-asset.table.ts index 8054009c39..567a6f9d4b 100644 --- a/server/src/schema/tables/album-asset.table.ts +++ b/server/src/schema/tables/album-asset.table.ts @@ -1,8 +1,18 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { album_assets_delete_audit } from 'src/schema/functions'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools'; +import { AfterDeleteTrigger, CreateDateColumn, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools'; @Table({ name: 'albums_assets_assets', primaryConstraintName: 'PK_c67bc36fa845fb7b18e0e398180' }) +@UpdatedAtTrigger('album_assets_updated_at') +@AfterDeleteTrigger({ + name: 'album_assets_delete_audit', + scope: 'statement', + function: album_assets_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() <= 1', +}) export class AlbumAssetTable { @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true }) albumsId!: string; @@ -12,4 +22,10 @@ export class AlbumAssetTable { @CreateDateColumn() createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @UpdateIdColumn({ indexName: 'IDX_album_assets_update_id' }) + updateId!: string; } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 733dd9036a..8293ae33b9 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -57,11 +57,14 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.UsersV1, SyncRequestType.PartnersV1, SyncRequestType.AssetsV1, - SyncRequestType.AssetExifsV1, SyncRequestType.PartnerAssetsV1, - SyncRequestType.PartnerAssetExifsV1, SyncRequestType.AlbumsV1, SyncRequestType.AlbumUsersV1, + SyncRequestType.AlbumAssetsV1, + SyncRequestType.AlbumToAssetsV1, + SyncRequestType.AssetExifsV1, + SyncRequestType.AlbumAssetExifsV1, + SyncRequestType.PartnerAssetExifsV1, ]; const throwSessionRequired = () => { @@ -164,6 +167,21 @@ export class SyncService extends BaseService { break; } + case SyncRequestType.AlbumAssetsV1: { + await this.syncAlbumAssetsV1(response, checkpointMap, auth, sessionId); + break; + } + + case SyncRequestType.AlbumToAssetsV1: { + await this.syncAlbumToAssetsV1(response, checkpointMap, auth, sessionId); + break; + } + + case SyncRequestType.AlbumAssetExifsV1: { + await this.syncAlbumAssetExifsV1(response, checkpointMap, auth, sessionId); + break; + } + default: { this.logger.warn(`Unsupported sync type: ${type}`); break; @@ -380,6 +398,147 @@ export class SyncService extends BaseService { } } + private async syncAlbumAssetsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) { + const backfillType = SyncEntityType.AlbumAssetBackfillV1; + const upsertType = SyncEntityType.AlbumAssetV1; + + const backfillCheckpoint = checkpointMap[backfillType]; + const upsertCheckpoint = checkpointMap[upsertType]; + + const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId); + + if (upsertCheckpoint) { + const endId = upsertCheckpoint.updateId; + + for (const album of albums) { + const createId = album.createId; + if (isEntityBackfillComplete(createId, backfillCheckpoint)) { + continue; + } + + const startId = getStartId(createId, backfillCheckpoint); + const backfill = this.syncRepository.getAlbumAssetsBackfill(album.id, startId, endId); + + for await (const { updateId, ...data } of backfill) { + send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV1(data) }); + } + + sendEntityBackfillCompleteAck(response, backfillType, createId); + } + } else if (albums.length > 0) { + await this.upsertBackfillCheckpoint({ + type: backfillType, + sessionId, + createId: albums.at(-1)!.createId, + }); + } + + const upserts = this.syncRepository.getAlbumAssetsUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) }); + } + } + + private async syncAlbumAssetExifsV1( + response: Writable, + checkpointMap: CheckpointMap, + auth: AuthDto, + sessionId: string, + ) { + const backfillType = SyncEntityType.AlbumAssetExifBackfillV1; + const upsertType = SyncEntityType.AlbumAssetExifV1; + + const backfillCheckpoint = checkpointMap[backfillType]; + const upsertCheckpoint = checkpointMap[upsertType]; + + const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId); + + if (upsertCheckpoint) { + const endId = upsertCheckpoint.updateId; + + for (const album of albums) { + const createId = album.createId; + if (isEntityBackfillComplete(createId, backfillCheckpoint)) { + continue; + } + + const startId = getStartId(createId, backfillCheckpoint); + const backfill = this.syncRepository.getAlbumAssetExifsBackfill(album.id, startId, endId); + + for await (const { updateId, ...data } of backfill) { + send(response, { type: backfillType, ids: [createId, updateId], data }); + } + + sendEntityBackfillCompleteAck(response, backfillType, createId); + } + } else if (albums.length > 0) { + await this.upsertBackfillCheckpoint({ + type: backfillType, + sessionId, + createId: albums.at(-1)!.createId, + }); + } + + const upserts = this.syncRepository.getAlbumAssetExifsUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + + private async syncAlbumToAssetsV1( + response: Writable, + checkpointMap: CheckpointMap, + auth: AuthDto, + sessionId: string, + ) { + const backfillType = SyncEntityType.AlbumToAssetBackfillV1; + const upsertType = SyncEntityType.AlbumToAssetV1; + + const backfillCheckpoint = checkpointMap[backfillType]; + const upsertCheckpoint = checkpointMap[upsertType]; + + const deletes = this.syncRepository.getAlbumToAssetDeletes( + auth.user.id, + checkpointMap[SyncEntityType.AlbumToAssetDeleteV1], + ); + for await (const { id, ...data } of deletes) { + send(response, { type: SyncEntityType.AlbumToAssetDeleteV1, ids: [id], data }); + } + + const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId); + + if (upsertCheckpoint) { + const endId = upsertCheckpoint.updateId; + + for (const album of albums) { + const createId = album.createId; + if (isEntityBackfillComplete(createId, backfillCheckpoint)) { + continue; + } + + const startId = getStartId(createId, backfillCheckpoint); + const backfill = this.syncRepository.getAlbumToAssetBackfill(album.id, startId, endId); + + for await (const { updateId, ...data } of backfill) { + send(response, { type: backfillType, ids: [createId, updateId], data }); + } + + sendEntityBackfillCompleteAck(response, backfillType, createId); + } + } else if (albums.length > 0) { + await this.upsertBackfillCheckpoint({ + type: backfillType, + sessionId, + createId: albums.at(-1)!.createId, + }); + } + + const upserts = this.syncRepository.getAlbumToAssetUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) { const { type, sessionId, createId } = item; await this.syncRepository.upsertCheckpoints([ diff --git a/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts b/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts new file mode 100644 index 0000000000..07ed8e5785 --- /dev/null +++ b/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts @@ -0,0 +1,222 @@ +import { Kysely } from 'kysely'; +import { DB } from 'src/db'; +import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum'; +import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB, wait } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const database = db || defaultDatabase; + const result = newSyncTest({ db: database }); + const { auth, create } = newSyncAuthUser(); + await create(database); + return { ...result, auth }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe.concurrent(SyncRequestType.AlbumAssetExifsV1, () => { + it('should detect and sync the first album asset exif', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + await userRepo.create(user2); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ ownerId: user2.id }); + await assetRepo.create(asset); + await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); + await albumRepo.create({ ownerId: user2.id }, [asset.id], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumAssetExifsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + city: null, + country: null, + dateTimeOriginal: null, + description: '', + exifImageHeight: null, + exifImageWidth: null, + exposureTime: null, + fNumber: null, + fileSizeInByte: null, + focalLength: null, + fps: null, + iso: null, + latitude: null, + lensModel: null, + longitude: null, + make: 'Canon', + model: null, + modifyDate: null, + orientation: null, + profileDescription: null, + projectionType: null, + rating: null, + state: null, + timeZone: null, + }, + type: SyncEntityType.AlbumAssetExifV1, + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumAssetExifsV1]); + + expect(ackSyncResponse).toEqual([]); + }); + + it('should sync album asset exif for own user', async () => { + const { auth, getRepository, testSync } = await setup(); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); + await assetRepo.create(asset); + await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); + await albumRepo.create({ ownerId: auth.user.id }, [asset.id], []); + + await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toHaveLength(1); + }); + + it('should not sync album asset exif for unrelated user', async () => { + const { auth, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + const user3 = mediumFactory.userInsert(); + await userRepo.create(user2); + await userRepo.create(user3); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ ownerId: user3.id }); + await assetRepo.create(asset); + await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); + await albumRepo.create({ ownerId: user2.id }, [asset.id], [{ userId: user3.id, role: AlbumUserRole.EDITOR }]); + + const sessionRepo = getRepository('session'); + const session = mediumFactory.sessionInsert({ userId: user3.id }); + await sessionRepo.create(session); + + const authUser3 = factory.auth({ session, user: user3 }); + + await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0); + }); + + it('should backfill album assets exif when a user shares an album with you', async () => { + const { auth, sut, testSync, getRepository } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + const user3 = mediumFactory.userInsert(); + await userRepo.create(user2); + await userRepo.create(user3); + + const assetRepo = getRepository('asset'); + // Asset to check that we do backfill our own assets + const asset1Owner = mediumFactory.assetInsert({ ownerId: auth.user.id }); + const asset1User2 = mediumFactory.assetInsert({ ownerId: user2.id }); + const asset2User2 = mediumFactory.assetInsert({ ownerId: user2.id }); + const asset3User2 = mediumFactory.assetInsert({ ownerId: user2.id }); + await assetRepo.create(asset1Owner); + await assetRepo.upsertExif({ assetId: asset1Owner.id, make: 'asset1Owner' }); + await wait(2); + await assetRepo.create(asset1User2); + await assetRepo.upsertExif({ assetId: asset1User2.id, make: 'asset1User2' }); + await wait(2); + await assetRepo.create(asset2User2); + await assetRepo.upsertExif({ assetId: asset2User2.id, make: 'asset2User2' }); + await wait(2); + await assetRepo.create(asset3User2); + await assetRepo.upsertExif({ assetId: asset3User2.id, make: 'asset3User2' }); + + const albumRepo = getRepository('album'); + const album1 = mediumFactory.albumInsert({ ownerId: user2.id }); + await albumRepo.create(album1, [asset2User2.id], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]); + + const response = await testSync(auth, [SyncRequestType.AlbumAssetExifsV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + assetId: asset2User2.id, + }), + type: SyncEntityType.AlbumAssetExifV1, + }, + ]); + + // ack initial album asset exif sync + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + // create a second album with + const album2 = mediumFactory.albumInsert({ ownerId: user2.id }); + await albumRepo.create( + album2, + [asset1User2.id, asset2User2.id, asset3User2.id, asset1Owner.id], + [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }], + ); + + // should backfill the album user + const backfillResponse = await testSync(auth, [SyncRequestType.AlbumAssetExifsV1]); + expect(backfillResponse).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + assetId: asset1Owner.id, + }), + type: SyncEntityType.AlbumAssetExifBackfillV1, + }, + { + ack: expect.any(String), + data: expect.objectContaining({ + assetId: asset1User2.id, + }), + type: SyncEntityType.AlbumAssetExifBackfillV1, + }, + { + ack: expect.any(String), + data: expect.objectContaining({ + assetId: asset2User2.id, + }), + type: SyncEntityType.AlbumAssetExifBackfillV1, + }, + { + ack: expect.stringContaining(SyncEntityType.AlbumAssetExifBackfillV1), + data: {}, + type: SyncEntityType.SyncAckV1, + }, + { + ack: expect.any(String), + data: expect.objectContaining({ + assetId: asset3User2.id, + }), + type: SyncEntityType.AlbumAssetExifV1, + }, + ]); + + await sut.setAcks(auth, { acks: [backfillResponse[3].ack, backfillResponse.at(-1).ack] }); + + const finalResponse = await testSync(auth, [SyncRequestType.AlbumAssetExifsV1]); + expect(finalResponse).toEqual([]); + }); +}); diff --git a/server/test/medium/specs/sync/sync-album-asset.spec.ts b/server/test/medium/specs/sync/sync-album-asset.spec.ts new file mode 100644 index 0000000000..ea16393f11 --- /dev/null +++ b/server/test/medium/specs/sync/sync-album-asset.spec.ts @@ -0,0 +1,218 @@ +import { Kysely } from 'kysely'; +import { DB } from 'src/db'; +import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum'; +import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB, wait } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const database = db || defaultDatabase; + const result = newSyncTest({ db: database }); + const { auth, create } = newSyncAuthUser(); + await create(database); + return { ...result, auth }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe.concurrent(SyncRequestType.AlbumAssetsV1, () => { + it('should detect and sync the first album asset', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + await userRepo.create(user2); + + const originalFileName = 'firstAsset'; + const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; + const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; + const date = new Date().toISOString(); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ + originalFileName, + ownerId: user2.id, + checksum: Buffer.from(checksum, 'base64'), + thumbhash: Buffer.from(thumbhash, 'base64'), + fileCreatedAt: date, + fileModifiedAt: date, + localDateTime: date, + deletedAt: null, + duration: '0:10:00.00000', + }); + await assetRepo.create(asset); + await albumRepo.create({ ownerId: user2.id }, [asset.id], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumAssetsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + id: asset.id, + originalFileName, + ownerId: asset.ownerId, + thumbhash, + checksum, + deletedAt: asset.deletedAt, + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + isFavorite: asset.isFavorite, + localDateTime: asset.localDateTime, + type: asset.type, + visibility: asset.visibility, + duration: asset.duration, + }, + type: SyncEntityType.AlbumAssetV1, + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumAssetsV1]); + + expect(ackSyncResponse).toEqual([]); + }); + + it('should sync album asset for own user', async () => { + const { auth, getRepository, testSync } = await setup(); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); + await assetRepo.create(asset); + await albumRepo.create({ ownerId: auth.user.id }, [asset.id], []); + + await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toHaveLength(1); + }); + + it('should not sync album asset for unrelated user', async () => { + const { auth, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + const user3 = mediumFactory.userInsert(); + await userRepo.create(user2); + await userRepo.create(user3); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ ownerId: user3.id }); + await assetRepo.create(asset); + await albumRepo.create({ ownerId: user2.id }, [asset.id], [{ userId: user3.id, role: AlbumUserRole.EDITOR }]); + + const sessionRepo = getRepository('session'); + const session = mediumFactory.sessionInsert({ userId: user3.id }); + await sessionRepo.create(session); + + const authUser3 = factory.auth({ session, user: user3 }); + + await expect(testSync(authUser3, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); + await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); + }); + + it('should backfill album assets when a user shares an album with you', async () => { + const { auth, sut, testSync, getRepository } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + const user3 = mediumFactory.userInsert(); + await userRepo.create(user2); + await userRepo.create(user3); + + const assetRepo = getRepository('asset'); + // Asset to check that we do backfill our own assets + const asset1Owner = mediumFactory.assetInsert({ ownerId: auth.user.id }); + const asset1User2 = mediumFactory.assetInsert({ ownerId: user2.id }); + const asset2User2 = mediumFactory.assetInsert({ ownerId: user2.id }); + const asset3User2 = mediumFactory.assetInsert({ ownerId: user2.id }); + await assetRepo.create(asset1Owner); + await wait(2); + await assetRepo.create(asset1User2); + await wait(2); + await assetRepo.create(asset2User2); + await wait(2); + await assetRepo.create(asset3User2); + + const albumRepo = getRepository('album'); + const album1 = mediumFactory.albumInsert({ ownerId: user2.id }); + await albumRepo.create(album1, [asset2User2.id], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]); + + const response = await testSync(auth, [SyncRequestType.AlbumAssetsV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: asset2User2.id, + }), + type: SyncEntityType.AlbumAssetV1, + }, + ]); + + // ack initial album asset sync + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + // create a second album with + const album2 = mediumFactory.albumInsert({ ownerId: user2.id }); + await albumRepo.create( + album2, + [asset1User2.id, asset2User2.id, asset3User2.id, asset1Owner.id], + [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }], + ); + + // should backfill the album user + const backfillResponse = await testSync(auth, [SyncRequestType.AlbumAssetsV1]); + expect(backfillResponse).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: asset1Owner.id, + }), + type: SyncEntityType.AlbumAssetBackfillV1, + }, + { + ack: expect.any(String), + data: expect.objectContaining({ + id: asset1User2.id, + }), + type: SyncEntityType.AlbumAssetBackfillV1, + }, + { + ack: expect.any(String), + data: expect.objectContaining({ + id: asset2User2.id, + }), + type: SyncEntityType.AlbumAssetBackfillV1, + }, + { + ack: expect.stringContaining(SyncEntityType.AlbumAssetBackfillV1), + data: {}, + type: SyncEntityType.SyncAckV1, + }, + { + ack: expect.any(String), + data: expect.objectContaining({ + id: asset3User2.id, + }), + type: SyncEntityType.AlbumAssetV1, + }, + ]); + + await sut.setAcks(auth, { acks: [backfillResponse[3].ack, backfillResponse.at(-1).ack] }); + + const finalResponse = await testSync(auth, [SyncRequestType.AlbumAssetsV1]); + expect(finalResponse).toEqual([]); + }); +}); diff --git a/server/test/medium/specs/sync/sync-album-to-asset.spec.ts b/server/test/medium/specs/sync/sync-album-to-asset.spec.ts new file mode 100644 index 0000000000..0941ab05b7 --- /dev/null +++ b/server/test/medium/specs/sync/sync-album-to-asset.spec.ts @@ -0,0 +1,350 @@ +import { Kysely } from 'kysely'; +import { DB } from 'src/db'; +import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum'; +import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; +import { getKyselyDB, wait } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const database = db || defaultDatabase; + const result = newSyncTest({ db: database }); + const { auth, create } = newSyncAuthUser(); + await create(database); + return { ...result, auth }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe.concurrent(SyncRequestType.AlbumToAssetsV1, () => { + it('should detect and sync the first album to asset relation', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + await userRepo.create(user2); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ ownerId: user2.id }); + await assetRepo.create(asset); + const album = mediumFactory.albumInsert({ ownerId: user2.id }); + await albumRepo.create(album, [asset.id], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + albumId: album.id, + assetId: asset.id, + }, + type: SyncEntityType.AlbumToAssetV1, + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + + expect(ackSyncResponse).toEqual([]); + }); + + it('should sync album to asset for owned albums', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); + await assetRepo.create(asset); + const album = mediumFactory.albumInsert({ ownerId: auth.user.id }); + await albumRepo.create(album, [asset.id], []); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + albumId: album.id, + assetId: asset.id, + }, + type: SyncEntityType.AlbumToAssetV1, + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + expect(ackSyncResponse).toEqual([]); + }); + + it('should detect and sync the album to asset for shared albums', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + await userRepo.create(user2); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); + await assetRepo.create(asset); + const album = mediumFactory.albumInsert({ ownerId: user2.id }); + await albumRepo.create(album, [asset.id], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + albumId: album.id, + assetId: asset.id, + }, + type: SyncEntityType.AlbumToAssetV1, + }, + ]), + ); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + + expect(ackSyncResponse).toEqual([]); + }); + + it('should not sync album to asset for an album owned by another user', async () => { + const { auth, getRepository, testSync } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + await userRepo.create(user2); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ ownerId: user2.id }); + await assetRepo.create(asset); + await albumRepo.create({ ownerId: user2.id }, [asset.id], []); + + await expect(testSync(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toHaveLength(0); + }); + + it('should backfill album to assets when a user shares an album with you', async () => { + const { auth, sut, testSync, getRepository } = await setup(); + + const userRepo = getRepository('user'); + const user2 = mediumFactory.userInsert(); + await userRepo.create(user2); + + const assetRepo = getRepository('asset'); + const album1Asset = mediumFactory.assetInsert({ ownerId: user2.id }); + await assetRepo.create(album1Asset); + const album2Asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); + await assetRepo.create(album2Asset); + + // Backfill album + const albumRepo = getRepository('album'); + const album2 = mediumFactory.albumInsert({ ownerId: user2.id }); + await albumRepo.create(album2, [album2Asset.id], []); + + await wait(2); + + const album1 = mediumFactory.albumInsert({ ownerId: auth.user.id }); + await albumRepo.create(album1, [album1Asset.id], []); + + const response = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + albumId: album1.id, + assetId: album1Asset.id, + }, + type: SyncEntityType.AlbumToAssetV1, + }, + ]); + + // ack initial album to asset sync + const acks = response.map(({ ack }) => ack); + await sut.setAcks(auth, { acks }); + + // add user to backfill album + const albumUserRepo = getRepository('albumUser'); + await albumUserRepo.create({ albumsId: album2.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR }); + + // should backfill the album to asset relation + const backfillResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + expect(backfillResponse).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + albumId: album2.id, + assetId: album2Asset.id, + }), + type: SyncEntityType.AlbumToAssetBackfillV1, + }, + { + ack: expect.stringContaining(SyncEntityType.AlbumToAssetBackfillV1), + data: {}, + type: SyncEntityType.SyncAckV1, + }, + ]); + + await sut.setAcks(auth, { acks: [backfillResponse[1].ack] }); + + const finalResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + expect(finalResponse).toEqual([]); + }); + + it('should detect and sync a deleted album to asset relation', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); + await assetRepo.create(asset); + const album = mediumFactory.albumInsert({ ownerId: auth.user.id }); + await albumRepo.create(album, [asset.id], []); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual([ + { + ack: expect.any(String), + data: { + albumId: album.id, + assetId: asset.id, + }, + type: SyncEntityType.AlbumToAssetV1, + }, + ]); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + await albumRepo.removeAssetIds(album.id, [asset.id]); + + await wait(2); + + const syncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + expect(syncResponse).toHaveLength(1); + expect(syncResponse).toEqual([ + { + ack: expect.any(String), + data: { + albumId: album.id, + assetId: asset.id, + }, + type: SyncEntityType.AlbumToAssetDeleteV1, + }, + ]); + + await sut.setAcks(auth, { acks: [syncResponse[0].ack] }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + expect(ackSyncResponse).toEqual([]); + }); + + it('should detect and sync a deleted album to asset relation when an asset is deleted', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); + await assetRepo.create(asset); + const album = mediumFactory.albumInsert({ ownerId: auth.user.id }); + await albumRepo.create(album, [asset.id], []); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual([ + { + ack: expect.any(String), + data: { + albumId: album.id, + assetId: asset.id, + }, + type: SyncEntityType.AlbumToAssetV1, + }, + ]); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + await assetRepo.remove({ id: asset.id }); + + await wait(2); + + const syncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + expect(syncResponse).toHaveLength(1); + expect(syncResponse).toEqual([ + { + ack: expect.any(String), + data: { + albumId: album.id, + assetId: asset.id, + }, + type: SyncEntityType.AlbumToAssetDeleteV1, + }, + ]); + + await sut.setAcks(auth, { acks: [syncResponse[0].ack] }); + + const ackSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + expect(ackSyncResponse).toEqual([]); + }); + + it('should not sync a deleted album to asset relation when the album is deleted', async () => { + const { auth, sut, getRepository, testSync } = await setup(); + + const albumRepo = getRepository('album'); + const assetRepo = getRepository('asset'); + const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); + await assetRepo.create(asset); + const album = mediumFactory.albumInsert({ ownerId: auth.user.id }); + await albumRepo.create(album, [asset.id], []); + + const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + + expect(initialSyncResponse).toHaveLength(1); + expect(initialSyncResponse).toEqual([ + { + ack: expect.any(String), + data: { + albumId: album.id, + assetId: asset.id, + }, + type: SyncEntityType.AlbumToAssetV1, + }, + ]); + + const acks = [initialSyncResponse[0].ack]; + await sut.setAcks(auth, { acks }); + + await albumRepo.delete(album.id); + + await wait(2); + + const syncResponse = await testSync(auth, [SyncRequestType.AlbumToAssetsV1]); + expect(syncResponse).toHaveLength(0); + expect(syncResponse).toEqual([]); + }); +});