From 881a96cdf99cadc30d4b247883dfcecce8a9d80b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 25 Jun 2025 12:10:31 -0400 Subject: [PATCH] feat: add album asset sync (#19503) wip: fix album asset exif and some other refactorings feat: add album assets sync feat: album to assets relation sync Co-authored-by: Zack Pollard --- mobile/openapi/README.md | Bin 37765 -> 37817 bytes mobile/openapi/lib/api.dart | Bin 13328 -> 13370 bytes mobile/openapi/lib/api_client.dart | Bin 33595 -> 33683 bytes .../lib/model/sync_album_to_asset_v1.dart | Bin 0 -> 3117 bytes .../openapi/lib/model/sync_entity_type.dart | Bin 5258 -> 6521 bytes .../openapi/lib/model/sync_request_type.dart | Bin 3614 -> 4112 bytes open-api/immich-openapi-specs.json | 31 +- open-api/typescript-sdk/src/fetch-client.ts | 12 +- server/src/database.ts | 35 +- server/src/db.d.ts | 10 + server/src/dtos/sync.dto.ts | 23 +- server/src/enum.ts | 10 + server/src/queries/sync.repository.sql | 310 +++++++++++++--- server/src/repositories/sync.repository.ts | 146 +++++++- server/src/schema/functions.ts | 14 + server/src/schema/index.ts | 2 + .../1750676477029-AlbumAssetUpdateId.ts | 18 + .../1750694237564-AlbumAssetAuditTable.ts | 22 ++ ...0780093818-AddAlbumToAssetDeleteTrigger.ts | 28 ++ .../schema/tables/album-asset-audit.table.ts | 23 ++ server/src/schema/tables/album-asset.table.ts | 18 +- server/src/services/sync.service.ts | 163 +++++++- .../specs/sync/sync-album-asset-exif.spec.ts | 222 +++++++++++ .../specs/sync/sync-album-asset.spec.ts | 218 +++++++++++ .../specs/sync/sync-album-to-asset.spec.ts | 350 ++++++++++++++++++ 25 files changed, 1565 insertions(+), 90 deletions(-) create mode 100644 mobile/openapi/lib/model/sync_album_to_asset_v1.dart create mode 100644 server/src/schema/migrations/1750676477029-AlbumAssetUpdateId.ts create mode 100644 server/src/schema/migrations/1750694237564-AlbumAssetAuditTable.ts create mode 100644 server/src/schema/migrations/1750780093818-AddAlbumToAssetDeleteTrigger.ts create mode 100644 server/src/schema/tables/album-asset-audit.table.ts create mode 100644 server/test/medium/specs/sync/sync-album-asset-exif.spec.ts create mode 100644 server/test/medium/specs/sync/sync-album-asset.spec.ts create mode 100644 server/test/medium/specs/sync/sync-album-to-asset.spec.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 590e45f04b08a8248744447d5dc6d8e1bfd21828..238a19e5d1143dea1085baf1b972a382448b076a 100644 GIT binary patch delta 51 zcmZo&&a`tm(+1rJg^+y5;^NejFvD1ll>B6U{ou;HWXGJO(p;#-W!T>N21?>O; diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 573081503f3e7b3d9118570dfc49162eb0fabdef..b9649b0ba3e5ed0e033a74d4f158c78776479d01 100644 GIT binary patch delta 25 hcmbP`u`6T4e`Su6{P@J;;?$DKfhw|_e<_Oz005R63UUAd delta 12 Tcmdm$F(G5af91`ZDuMz4D3ApX diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 28b67f52c53ce085840e4bf8cd63076b4c5c3efe..dd37b628abe4fc93b8d421a6bf5f9c0be09e3914 100644 GIT binary patch delta 43 tcmdnp#x%K|X~WAX_K(o 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 0000000000000000000000000000000000000000..6908f320f8060fa21a93f1c5c81f5308f3924518 GIT binary patch literal 3117 zcmbVOZExE)5dQ98aV>^g!BlzOry`xb21_!eYh$4G3JgXdFcNLElSPfBY8Yw$`|ggE zEKkak?L$+Gyf;42b4N}lqsau$eqYRA{(5nJ@$qbNaSoSP?=NCFpTqTh0Uze)SC@Z0 zLo>2`n+xNnKPInUkLXjaOKoJj(k5N0LY_ih)>h`JEaX;}E^fZnw$jE8dWd2t_BO3* zmm2w3tqi)CVvBz(Veo$^jm6-`4!dW%v`&;RRXHY9Dc&9(3LDjr4s!2W;DtQVJ&>xm)TiyTW=To%v$L_zk<6Fzyu-qC|zwz z3j#{HgJs|H1{9Z=$pl^c`w>L~Xbul;42Q#F);gat*=fXvy31 z(p&vi8iO#Q$F0@{9n=V?4NRxe{Pv5?@c3>c%?V6r;Ni?f5m7yHv-9nn{{?Cglov0! zU=Guq0mC1Pi5p1MYmd&C7 zNz(YA!1rOpCp>KQAPS&5?SDj!%D7M8GUS+k*Kh`?7-9%l{EwS4NB%03mTt2YH(u_mO@eNvcb@NX%Uq{ExFL*P<3g(cs za8Hu@kIDv7=HQ#$f^rtVilUYlRz`0_v?VWLGpx{HO#QM%KyG3v>jJe%!rLj!Jb(^;0M_3G0XSlg10|WTuE$~Id)Bu+jgEnmBhE!8f#6fvit00AI$tgJ zl?+j55h*2xgAh)h`H{iCp5(`UgE|hr;js5yMvw>5)XF1({a(=ld$Vx{1#Vm-_eZGv zTJL!Ftc~7@+X5RJR-&*nzHgB9I2vi|!f_Au!!K_RWKdAzf=a>h!YWRbz1HqxTe*D` z75TwI7lq(@hclT?5Bm*+OdTffA1fg>^NFzd01@GQ(8RMTtGOGL20I-k5qa*F)A95G zQ_E4DBe;W?({!+?f$J2SUmS&7HE_|LF8$hPB%nb?nqoaPXvLuOjS>%lFl%NIow@-Z zbN?&$Y&Z4XJk%68a>8@s6xy3V#R-aU40=oWiGIW*u9|(rWI|s`+U7ijs6W$F80An2 zTXy()gjob@=^g)+dm6#)@3na#v?2D0UUY4n2@j5+G!Ypm?XTjUavNNtPNG*ypVF7! z5dFfq#k&L@=z&J@P*MZRATvj3x@C(7d~QVY|yGZ7wD z5}xds>OLO{QPslN5mekZ?V)|+R%HXdkI6dXn80EqTOG8|M3kJxJ6|B#HA>`G0^XGt v(^5kSc=u_3P4CU10lq-nH|B5T+rBSmcMM4TnD95qI6Pj)^oj2doHc&~SnBIy literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 654ff45d6f9d74049628103070d30b277df264c6..7129ebc0a4abe5584a49c908d6416979c1559572 100644 GIT binary patch delta 462 zcmeCu{Ask|JR4hLPEu*E<79niSxzvkxHz?B@sHjx{o5y*y_$qr-ZGs*FQO@#?eWRm6q8x9iy+RF}A z0kV1XdA84tGGG;8)4~jOxD6TLsSeb=US1Oh9W=NgG0_1YA}ifRLmtcC$%IM zn~YOpa&}r~PR{0C9DQ7qKM1l;julXtEFi=R4V{C0!eG(G0vbr74}}yadkd<7L>L_> zTMI~o)yx#s0f|gLBdEy~k`FTo9wbnsKvF*i)IciXQjPplY><$g47CU@^-xH9vxl$~ FBLIq$mKp#6 delta 22 ecmexq)TO!MJlp1X9G@9CcXPLKZ8n#1VgvwshY245 diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index c149c329dec07373bf2f25e6b72ce2fca7a4d750..c3cce99b1aaff8e9c4fd882538543322d556b39c 100644 GIT binary patch delta 207 zcmbOyGeKd4KFj3I%py!7`I8Nq`ea$}k3=a>;)&_26Hi#*c^>`#% a9l; + 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 91c93fef6..b552f52a3 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 4f3fd9a52..bbe8f001d 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 26aaf014d..46b72ffca 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 341966c6f..0d44af4bb 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 a03f715bf..fdef5867b 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 d2f8d80af..bab3acb23 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 000000000..8feba6f11 --- /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 000000000..0191b5d07 --- /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 000000000..f3cb75d10 --- /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 000000000..d2b71aa59 --- /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 8054009c3..567a6f9d4 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 733dd9036..8293ae33b 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 000000000..07ed8e578 --- /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 000000000..ea16393f1 --- /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 000000000..0941ab05b --- /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([]); + }); +});