From 54e7ffa4e679e53525fbf1bbf94b970f7efe3f4f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 16 Sep 2026 16:27:01 -0400 Subject: [PATCH] feat: asset face v3 (#31591) --- .../domain/services/sync_stream.service.dart | 2 + .../repositories/sync_api.repository.dart | 2 +- .../repositories/sync_stream.repository.dart | 2 +- open-api/immich-openapi-specs.json | 4 +- packages/sdk/src/fetch-client.ts | 4 +- server/src/database.ts | 14 ++ server/src/dtos/sync.dto.ts | 10 +- server/src/enum.ts | 5 + server/src/queries/sync.repository.sql | 79 ++++++-- server/src/repositories/sync.repository.ts | 48 +++-- server/src/services/sync.service.ts | 21 +- .../medium/specs/sync/sync-asset-face.spec.ts | 184 ++++++++++++++++++ 12 files changed, 336 insertions(+), 39 deletions(-) diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index b168a0be6..710908e58 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -325,6 +325,8 @@ class SyncStreamService { return _syncStreamRepository.updateAssetFacesV1(data.cast()); case SyncEntityType.assetFaceV2: return _syncStreamRepository.updateAssetFacesV2(data.cast()); + case SyncEntityType.assetFaceV3: + throw UnimplementedError('SyncEntityType.assetFaceV3 is not implemented yet'); case SyncEntityType.assetFaceDeleteV1: return _syncStreamRepository.deleteAssetFacesV1(data.cast()); case SyncEntityType.assetOcrV1: diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 8962dafcc..52b13e504 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -194,7 +194,7 @@ const _kResponseMap = { SyncEntityType.personV1: SyncPersonV1.fromJson, SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson, SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson, - SyncEntityType.assetFaceV2: SyncAssetFaceV2.fromJson, + SyncEntityType.assetFaceV2: SyncAssetFaceV3.fromJson, SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson, SyncEntityType.assetOcrV1: SyncAssetOcrV1.fromJson, SyncEntityType.assetOcrDeleteV1: SyncAssetOcrDeleteV1.fromJson, diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index b7251d17f..3f3b1b7a4 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -840,7 +840,7 @@ class SyncStreamRepository extends DatabaseAccessor with $SyncStreamRepos } } - Future updateAssetFacesV2(Iterable data) async { + Future updateAssetFacesV2(Iterable data) async { try { await _db.batch((batch) { for (final assetFace in data) { diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7b3389c0f..c333dd7be 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -30089,7 +30089,7 @@ ], "type": "object" }, - "SyncAssetFaceV2": { + "SyncAssetFaceV3": { "properties": { "assetId": { "description": "Asset ID", @@ -30863,6 +30863,7 @@ "PersonDeleteV1", "AssetFaceV1", "AssetFaceV2", + "AssetFaceV3", "AssetFaceDeleteV1", "UserMetadataV1", "UserMetadataDeleteV1", @@ -31188,6 +31189,7 @@ "PeopleV1", "AssetFacesV1", "AssetFacesV2", + "AssetFacesV3", "UserMetadataV1" ], "type": "string" diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 645c398bc..4e87ba7c2 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -3407,7 +3407,7 @@ export type SyncAssetFaceV1 = { /** Source type */ sourceType: string; }; -export type SyncAssetFaceV2 = { +export type SyncAssetFaceV3 = { /** Asset ID */ assetId: string; /** Bounding box X1 */ @@ -8386,6 +8386,7 @@ export enum SyncEntityType { PersonDeleteV1 = "PersonDeleteV1", AssetFaceV1 = "AssetFaceV1", AssetFaceV2 = "AssetFaceV2", + AssetFaceV3 = "AssetFaceV3", AssetFaceDeleteV1 = "AssetFaceDeleteV1", UserMetadataV1 = "UserMetadataV1", UserMetadataDeleteV1 = "UserMetadataDeleteV1", @@ -8421,6 +8422,7 @@ export enum SyncRequestType { PeopleV1 = "PeopleV1", AssetFacesV1 = "AssetFacesV1", AssetFacesV2 = "AssetFacesV2", + AssetFacesV3 = "AssetFacesV3", UserMetadataV1 = "UserMetadataV1" } export enum AssetOrderBy { diff --git a/server/src/database.ts b/server/src/database.ts index 7883b049d..783db2d68 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -471,6 +471,20 @@ export const columns = { syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'], stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'], + syncAssetFace: [ + 'asset_face.id', + 'asset_face.assetId', + 'asset_face.personGroupId as personId', + 'asset_face.imageWidth', + 'asset_face.imageHeight', + 'asset_face.boundingBoxX1', + 'asset_face.boundingBoxY1', + 'asset_face.boundingBoxX2', + 'asset_face.boundingBoxY2', + 'asset_face.sourceType', + 'asset_face.isVisible', + 'asset_face.deletedAt', + ], syncAssetExif: [ 'asset_exif.assetId', 'asset_exif.description', diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 6659fe163..1b4495d37 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -371,10 +371,11 @@ const SyncAssetFaceV1Schema = z }) .meta({ id: 'SyncAssetFaceV1' }); -const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.extend({ +// same shape as V2, but scoped to the whole cluster group instead of the user's own assets +const SyncAssetFaceV3Schema = SyncAssetFaceV1Schema.extend({ deletedAt: isoDatetimeToDate.nullable().describe('Face deleted at'), isVisible: z.boolean().describe('Is the face visible in the asset'), -}).meta({ id: 'SyncAssetFaceV2' }); +}).meta({ id: 'SyncAssetFaceV3' }); const SyncAssetFaceDeleteV1Schema = z .object({ assetFaceId: z.uuidv4().describe('Asset face ID') }) @@ -453,7 +454,7 @@ class SyncPersonDeleteV1 extends createZodDto(SyncPersonDeleteV1Schema) {} @ExtraModel() class SyncAssetFaceV1 extends createZodDto(SyncAssetFaceV1Schema) {} @ExtraModel() -class SyncAssetFaceV2 extends createZodDto(SyncAssetFaceV2Schema) {} +class SyncAssetFaceV3 extends createZodDto(SyncAssetFaceV3Schema) {} @ExtraModel() class SyncAssetFaceDeleteV1 extends createZodDto(SyncAssetFaceDeleteV1Schema) {} @ExtraModel() @@ -515,7 +516,8 @@ export type SyncItem = { [SyncEntityType.PersonV1]: SyncPersonV1; [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; [SyncEntityType.AssetFaceV1]: SyncAssetFaceV1; - [SyncEntityType.AssetFaceV2]: SyncAssetFaceV2; + [SyncEntityType.AssetFaceV2]: SyncAssetFaceV3; + [SyncEntityType.AssetFaceV3]: SyncAssetFaceV3; [SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1; [SyncEntityType.UserMetadataV1]: SyncUserMetadataV1; [SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index 232f7b22b..75f6f0fc0 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1038,7 +1038,9 @@ export enum SyncRequestType { PeopleV1 = 'PeopleV1', /** @deprecated */ AssetFacesV1 = 'AssetFacesV1', + /** @deprecated */ AssetFacesV2 = 'AssetFacesV2', + AssetFacesV3 = 'AssetFacesV3', UserMetadataV1 = 'UserMetadataV1', } @@ -1119,8 +1121,11 @@ export enum SyncEntityType { PersonV1 = 'PersonV1', PersonDeleteV1 = 'PersonDeleteV1', + /** @deprecated */ AssetFaceV1 = 'AssetFaceV1', + /** @deprecated */ AssetFaceV2 = 'AssetFaceV2', + AssetFaceV3 = 'AssetFaceV3', AssetFaceDeleteV1 = 'AssetFaceDeleteV1', UserMetadataV1 = 'UserMetadataV1', diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 15c5a73f2..3014704f1 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -518,7 +518,7 @@ where order by "asset_edit"."updateId" asc --- SyncRepository.assetFace.getDeletes +-- SyncRepository.assetFace.getDeletesV2 select "asset_face_audit"."id", "assetFaceId" @@ -532,19 +532,41 @@ where order by "asset_face_audit"."id" asc --- SyncRepository.assetFace.getUpserts +-- SyncRepository.assetFace.getDeletesV3 +select + "asset_face_audit"."id", + "assetFaceId" +from + "asset_face_audit" as "asset_face_audit" + inner join "asset" on "asset"."id" = "asset_face_audit"."assetId" + inner join "user" as "owner" on "owner"."id" = "asset"."ownerId" +where + "asset_face_audit"."id" < $1 + and "asset_face_audit"."id" > $2 + and "owner"."clusterGroupId" = ( + select + "user"."clusterGroupId" + from + "user" + where + "user"."id" = $3 + ) +order by + "asset_face_audit"."id" asc + +-- SyncRepository.assetFace.getUpsertsV2 select "asset_face"."id", - "assetId", - "personGroupId" as "personId", - "imageWidth", - "imageHeight", - "boundingBoxX1", - "boundingBoxY1", - "boundingBoxX2", - "boundingBoxY2", - "sourceType", - "isVisible", + "asset_face"."assetId", + "asset_face"."personGroupId" as "personId", + "asset_face"."imageWidth", + "asset_face"."imageHeight", + "asset_face"."boundingBoxX1", + "asset_face"."boundingBoxY1", + "asset_face"."boundingBoxX2", + "asset_face"."boundingBoxY2", + "asset_face"."sourceType", + "asset_face"."isVisible", "asset_face"."deletedAt", "asset_face"."updateId" from @@ -557,6 +579,39 @@ where order by "asset_face"."updateId" asc +-- SyncRepository.assetFace.getUpsertsV3 +select + "asset_face"."id", + "asset_face"."assetId", + "asset_face"."personGroupId" as "personId", + "asset_face"."imageWidth", + "asset_face"."imageHeight", + "asset_face"."boundingBoxX1", + "asset_face"."boundingBoxY1", + "asset_face"."boundingBoxX2", + "asset_face"."boundingBoxY2", + "asset_face"."sourceType", + "asset_face"."isVisible", + "asset_face"."deletedAt", + "asset_face"."updateId" +from + "asset_face" as "asset_face" + inner join "asset" on "asset"."id" = "asset_face"."assetId" + inner join "user" as "owner" on "owner"."id" = "asset"."ownerId" +where + "asset_face"."updateId" < $1 + and "asset_face"."updateId" > $2 + and "owner"."clusterGroupId" = ( + select + "user"."clusterGroupId" + from + "user" + where + "user"."id" = $3 + ) +order by + "asset_face"."updateId" asc + -- SyncRepository.assetMetadata.getDeletes select "asset_metadata_audit"."id", diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 09be860c5..0c84d21a0 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -461,8 +461,9 @@ class PersonGroupSync extends BaseSync { } class AssetFaceSync extends BaseSync { + // TODO(v5) drop when AssetFacesV2 is removed @GenerateSql({ params: [dummyQueryOptions], stream: true }) - getDeletes(options: SyncQueryOptions) { + getDeletesV2(options: SyncQueryOptions) { return this.auditQuery('asset_face_audit', options) .select(['asset_face_audit.id', 'assetFaceId']) .leftJoin('asset', 'asset.id', 'asset_face_audit.assetId') @@ -470,32 +471,45 @@ class AssetFaceSync extends BaseSync { .stream(); } + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getDeletesV3(options: SyncQueryOptions) { + return this.auditQuery('asset_face_audit', options) + .select(['asset_face_audit.id', 'assetFaceId']) + .innerJoin('asset', 'asset.id', 'asset_face_audit.assetId') + .innerJoin('user as owner', 'owner.id', 'asset.ownerId') + .where('owner.clusterGroupId', '=', ({ selectFrom }) => + selectFrom('user').select('user.clusterGroupId').where('user.id', '=', options.userId), + ) + .stream(); + } + cleanupAuditTable(daysAgo: number) { return this.auditCleanup('asset_face_audit', daysAgo); } + // TODO(v5) drop when AssetFacesV2 is removed @GenerateSql({ params: [dummyQueryOptions], stream: true }) - getUpserts(options: SyncQueryOptions) { + getUpsertsV2(options: SyncQueryOptions) { return this.upsertQuery('asset_face', options) - .select([ - 'asset_face.id', - 'assetId', - 'personGroupId as personId', - 'imageWidth', - 'imageHeight', - 'boundingBoxX1', - 'boundingBoxY1', - 'boundingBoxX2', - 'boundingBoxY2', - 'sourceType', - 'isVisible', - 'asset_face.deletedAt', - 'asset_face.updateId', - ]) + .select(columns.syncAssetFace) + .select('asset_face.updateId') .leftJoin('asset', 'asset.id', 'asset_face.assetId') .where('asset.ownerId', '=', options.userId) .stream(); } + + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getUpsertsV3(options: SyncQueryOptions) { + return this.upsertQuery('asset_face', options) + .select(columns.syncAssetFace) + .select('asset_face.updateId') + .innerJoin('asset', 'asset.id', 'asset_face.assetId') + .innerJoin('user as owner', 'owner.id', 'asset.ownerId') + .where('owner.clusterGroupId', '=', ({ selectFrom }) => + selectFrom('user').select('user.clusterGroupId').where('user.id', '=', options.userId), + ) + .stream(); + } } class AssetExifSync extends BaseSync { diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 9f694974a..1dc75d994 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -87,6 +87,7 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.PeopleV1, SyncRequestType.AssetFacesV1, SyncRequestType.AssetFacesV2, + SyncRequestType.AssetFacesV3, SyncRequestType.UserMetadataV1, SyncRequestType.AssetMetadataV1, SyncRequestType.AssetEditsV1, @@ -214,6 +215,7 @@ export class SyncService extends BaseService { [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id), [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap), [SyncRequestType.AssetFacesV2]: () => this.syncAssetFacesV2(options, response, checkpointMap), + [SyncRequestType.AssetFacesV3]: () => this.syncAssetFacesV3(options, response, checkpointMap), [SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap), [SyncRequestType.AssetOcrV1]: () => this.syncAssetOcrV1(options, response, checkpointMap, auth), } as const; @@ -896,15 +898,30 @@ export class SyncService extends BaseService { ); } + // TODO(v5) drop when AssetFacesV2 is removed private async syncAssetFacesV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { const deleteType = SyncEntityType.AssetFaceDeleteV1; - const deletes = this.syncRepository.assetFace.getDeletes({ ...options, ack: checkpointMap[deleteType] }); + const deletes = this.syncRepository.assetFace.getDeletesV2({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AssetFaceV2; - const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); + const upserts = this.syncRepository.assetFace.getUpsertsV2({ ...options, ack: checkpointMap[upsertType] }); + for await (const { updateId, ...data } of upserts) { + await send(response, { type: upsertType, ids: [updateId], data }); + } + } + + private async syncAssetFacesV3(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + const deleteType = SyncEntityType.AssetFaceDeleteV1; + const deletes = this.syncRepository.assetFace.getDeletesV3({ ...options, ack: checkpointMap[deleteType] }); + for await (const { id, ...data } of deletes) { + await send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.AssetFaceV3; + const upserts = this.syncRepository.assetFace.getUpsertsV3({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { await send(response, { type: upsertType, ids: [updateId], data }); } diff --git a/server/test/medium/specs/sync/sync-asset-face.spec.ts b/server/test/medium/specs/sync/sync-asset-face.spec.ts index de43d9af9..4248fe9bf 100644 --- a/server/test/medium/specs/sync/sync-asset-face.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-face.spec.ts @@ -228,3 +228,187 @@ describe(SyncEntityType.AssetFaceV2, () => { await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); }); }); + +describe(SyncEntityType.AssetFaceV3, () => { + it('should detect and sync the first asset face', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV3]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + id: assetFace.id, + assetId: asset.id, + personId: person.personGroupId, + imageWidth: assetFace.imageWidth, + imageHeight: assetFace.imageHeight, + boundingBoxX1: assetFace.boundingBoxX1, + boundingBoxY1: assetFace.boundingBoxY1, + boundingBoxX2: assetFace.boundingBoxX2, + boundingBoxY2: assetFace.boundingBoxY2, + sourceType: assetFace.sourceType, + deletedAt: null, + isVisible: true, + }, + type: SyncEntityType.AssetFaceV3, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV3]); + }); + + it('should sync an asset face without a person', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV3]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ id: assetFace.id, assetId: asset.id, personId: null }), + type: SyncEntityType.AssetFaceV3, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + }); + + it('should sync an asset face belonging to another user in the same cluster group', async () => { + const { auth, user, ctx } = await setup(); + const { user: user2 } = await ctx.newUser({ clusterGroupId: user.clusterGroupId }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const { person } = await ctx.newPerson({ ownerId: user2.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV3]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ id: assetFace.id, assetId: asset.id, personId: person.personGroupId }), + type: SyncEntityType.AssetFaceV3, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV3]); + }); + + it('should detect and sync an updated asset face for another user in the same cluster group', async () => { + const { auth, user, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { user: user2 } = await ctx.newUser({ clusterGroupId: user.clusterGroupId }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const { person } = await ctx.newPerson({ ownerId: user2.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV3]); + expect(response).toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetFaceV3 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV3]); + + await personRepo.softDeleteAssetFaces(assetFace.id); + + expect(await ctx.syncStream(auth, [SyncRequestType.AssetFacesV3])).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ id: assetFace.id, deletedAt: expect.any(String) }), + type: SyncEntityType.AssetFaceV3, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + }); + + it('should detect and sync a deleted asset face', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); + await personRepo.deleteAssetFace(assetFace.id); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV3]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + assetFaceId: assetFace.id, + }, + type: SyncEntityType.AssetFaceDeleteV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV3]); + }); + + it('should detect and sync a deleted asset face belonging to another user in the same cluster group', async () => { + const { auth, user, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { user: user2 } = await ctx.newUser({ clusterGroupId: user.clusterGroupId }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const { person } = await ctx.newPerson({ ownerId: user2.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); + await personRepo.deleteAssetFace(assetFace.id); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV3]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + assetFaceId: assetFace.id, + }, + type: SyncEntityType.AssetFaceDeleteV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + }); + + it('should not sync an asset face or asset face delete for a user in a different cluster group', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { user: user2 } = await ctx.newUser(); + const { session } = await ctx.newSession({ userId: user2.id }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const { person } = await ctx.newPerson({ ownerId: user2.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); + const auth2 = factory.auth({ session, user: user2 }); + + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV3])).toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetFaceV3 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV3]); + + await personRepo.deleteAssetFace(assetFace.id); + + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV3])).toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetFaceDeleteV1 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV3]); + }); + + it('should detect and sync a deleted asset face without a person', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); + await personRepo.deleteAssetFace(assetFace.id); + + expect(await ctx.syncStream(auth, [SyncRequestType.AssetFacesV3])).toEqual([ + { ack: expect.any(String), data: { assetFaceId: assetFace.id }, type: SyncEntityType.AssetFaceDeleteV1 }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + }); +});