From df318ac641fce158af944cecb1dcf1e1fd9192f4 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Tue, 22 Jul 2025 02:31:45 +0100 Subject: [PATCH] feat: asset face sync (#20048) * chore: remove thumbnailPath from person sync dto * feat: asset face sync --- .../repositories/sync_stream.repository.dart | 1 - mobile/openapi/README.md | Bin 38598 -> 38702 bytes mobile/openapi/lib/api.dart | Bin 13907 -> 13990 bytes mobile/openapi/lib/api_client.dart | Bin 34833 -> 35009 bytes .../lib/model/sync_asset_face_delete_v1.dart | Bin 0 -> 2987 bytes .../openapi/lib/model/sync_asset_face_v1.dart | Bin 0 -> 5338 bytes .../openapi/lib/model/sync_entity_type.dart | Bin 8772 -> 9090 bytes mobile/openapi/lib/model/sync_person_v1.dart | Bin 5630 -> 5323 bytes .../openapi/lib/model/sync_request_type.dart | Bin 5013 -> 5164 bytes open-api/immich-openapi-specs.json | 66 ++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 3 + server/src/database.ts | 2 + server/src/dtos/sync.dto.ts | 22 ++++- server/src/enum.ts | 4 + server/src/queries/sync.repository.sql | 36 ++++++- server/src/repositories/sync.repository.ts | 49 +++++++++- server/src/schema/functions.ts | 13 +++ server/src/schema/index.ts | 5 + ...04909784-AssetFaceUpdateIdAndAuditTable.ts | 52 ++++++++++ .../schema/tables/asset-face-audit.table.ts | 17 ++++ server/src/schema/tables/asset-face.table.ts | 17 ++++ server/src/services/sync.service.ts | 16 +++ server/test/fixtures/face.stub.ts | 16 +++ server/test/medium.factory.ts | 6 ++ .../medium/specs/sync/sync-asset-face.spec.ts | 92 ++++++++++++++++++ .../medium/specs/sync/sync-person.spec.ts | 1 - 26 files changed, 407 insertions(+), 11 deletions(-) create mode 100644 mobile/openapi/lib/model/sync_asset_face_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_asset_face_v1.dart create mode 100644 server/src/schema/migrations/1753104909784-AssetFaceUpdateIdAndAuditTable.ts create mode 100644 server/src/schema/tables/asset-face-audit.table.ts create mode 100644 server/test/medium/specs/sync/sync-asset-face.spec.ts diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index fb5c7fdb3..e141c387b 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -523,7 +523,6 @@ class SyncStreamRepository extends DriftDatabaseRepository { ownerId: Value(person.ownerId), name: Value(person.name), faceAssetId: Value(person.faceAssetId), - thumbnailPath: Value(person.thumbnailPath), isFavorite: Value(person.isFavorite), isHidden: Value(person.isHidden), color: Value(person.color), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 28fa63ba84d98206ab0016fac27e8326f6bb04e4..3e7fa4c2f13b01464594feef61e40592ced53972 100644 GIT binary patch delta 67 zcmX@MmTBEOrVUC>EN+R(sgoBr3v)Ua7pInhdAAy+BteWY!&r@!{A7Lo;L5yYnDpfP MjiQ^CnwE$F0C3V7%K!iX delta 14 WcmZ3tj_KH1rVUC>o4cBphyVaH*#@5g diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index becafa06bf5ed69a9ef76d598941a4602ae2848a..545955a184518c713bac9fa547152f9f03708026 100644 GIT binary patch delta 38 qcmcbdvn+RmlPXJEVsh%_0CnZb?^M_zoQ*27li#bbZgx@?6a)Yzi4D{M delta 12 TcmZ3MdpT!=lj`QFs)B+5CoTl} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 603163f00ef981c345937893118a1555889e3b16..55d6f4108b92086be8eb994ddbabfe3ae2e07faf 100644 GIT binary patch delta 62 zcmbO@f$88xrVX=WS=&k!j)trVX=WH+#pGX#oH*#0DGy diff --git a/mobile/openapi/lib/model/sync_asset_face_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_face_delete_v1.dart new file mode 100644 index 0000000000000000000000000000000000000000..0992bfdcba6b6c2598a1fe73e47cc7e654b6b98b GIT binary patch literal 2987 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 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 61f94401c7c95ec29eac41258914fec802090361..65ed78105c55fb307e332c34629af8548827ed40 100644 GIT binary patch delta 147 zcmX@&(&WD3B{yecadB#iTVitRra<1EvA9O9hhwll=zmvj_+Z1G7a7%>%Pq4|@g_b#P>1 TbY)~gbSEqdARv7^3VjL+>DUzz delta 236 zcmX@D`A>U8H4|@1Mrm$RUSeiWKw?S8("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('asset_face_audit.id', '>', ack!.updateId)) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('asset_face') + .select([ + 'asset_face.id', + 'assetId', + 'personId', + 'imageWidth', + 'imageHeight', + 'boundingBoxX1', + 'boundingBoxY1', + 'boundingBoxX2', + 'boundingBoxY2', + 'sourceType', + 'asset_face.updateId', + ]) + .where('asset_face.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('asset_face.updateId', '>', ack!.updateId)) + .orderBy('asset_face.updateId', 'asc') + .leftJoin('asset', 'asset.id', 'asset_face.assetId') + .where('asset.ownerId', '=', userId) + .stream(); + } +} + class AssetExifSync extends BaseSync { @GenerateSql({ params: [DummyValue.UUID], stream: true }) getUpserts(userId: string, ack?: SyncAck) { diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 557716922..786e7a1ff 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -229,3 +229,16 @@ export const user_metadata_audit = registerFunction({ RETURN NULL; END`, }); + +export const asset_face_audit = registerFunction({ + name: 'asset_face_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO asset_face_audit ("assetFaceId", "assetId") + SELECT "id", "assetId" + FROM OLD; + RETURN NULL; + END`, +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index ba25a65d4..8982437b3 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -4,6 +4,7 @@ import { album_user_after_insert, album_user_delete_audit, asset_delete_audit, + asset_face_audit, f_concat_ws, f_unaccent, immich_uuid_v7, @@ -27,6 +28,7 @@ import { AlbumTable } from 'src/schema/tables/album.table'; import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; @@ -78,6 +80,7 @@ export class ImmichDatabase { ApiKeyTable, AssetAuditTable, AssetFaceTable, + AssetFaceAuditTable, AssetJobStatusTable, AssetTable, AssetFileTable, @@ -132,6 +135,7 @@ export class ImmichDatabase { stack_delete_audit, person_delete_audit, user_metadata_audit, + asset_face_audit, ]; enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum]; @@ -158,6 +162,7 @@ export interface DB { asset: AssetTable; asset_exif: AssetExifTable; asset_face: AssetFaceTable; + asset_face_audit: AssetFaceAuditTable; asset_file: AssetFileTable; asset_job_status: AssetJobStatusTable; asset_audit: AssetAuditTable; diff --git a/server/src/schema/migrations/1753104909784-AssetFaceUpdateIdAndAuditTable.ts b/server/src/schema/migrations/1753104909784-AssetFaceUpdateIdAndAuditTable.ts new file mode 100644 index 000000000..1f4072e34 --- /dev/null +++ b/server/src/schema/migrations/1753104909784-AssetFaceUpdateIdAndAuditTable.ts @@ -0,0 +1,52 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION asset_face_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO asset_face_audit ("assetFaceId", "assetId") + SELECT "id", "assetId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "asset_face_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "assetFaceId" uuid NOT NULL, + "assetId" uuid NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "asset_face_audit_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "asset_face_audit_assetFaceId_idx" ON "asset_face_audit" ("assetFaceId");`.execute(db); + await sql`CREATE INDEX "asset_face_audit_assetId_idx" ON "asset_face_audit" ("assetId");`.execute(db); + await sql`CREATE INDEX "asset_face_audit_deletedAt_idx" ON "asset_face_audit" ("deletedAt");`.execute(db); + await sql`ALTER TABLE "asset_face" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`ALTER TABLE "asset_face" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_face_audit" + AFTER DELETE ON "asset_face" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION asset_face_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_face_updatedAt" + BEFORE UPDATE ON "asset_face" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_face_audit', '{"type":"function","name":"asset_face_audit","sql":"CREATE OR REPLACE FUNCTION asset_face_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_face_audit (\\"assetFaceId\\", \\"assetId\\")\\n SELECT \\"id\\", \\"assetId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_face_audit', '{"type":"trigger","name":"asset_face_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_face_audit\\"\\n AFTER DELETE ON \\"asset_face\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_face_audit();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_face_updatedAt', '{"type":"trigger","name":"asset_face_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"asset_face_updatedAt\\"\\n BEFORE UPDATE ON \\"asset_face\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "asset_face_audit" ON "asset_face";`.execute(db); + await sql`DROP TRIGGER "asset_face_updatedAt" ON "asset_face";`.execute(db); + await sql`ALTER TABLE "asset_face" DROP COLUMN "updatedAt";`.execute(db); + await sql`ALTER TABLE "asset_face" DROP COLUMN "updateId";`.execute(db); + await sql`DROP TABLE "asset_face_audit";`.execute(db); + await sql`DROP FUNCTION asset_face_audit;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_face_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_face_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_face_updatedAt';`.execute(db); +} diff --git a/server/src/schema/tables/asset-face-audit.table.ts b/server/src/schema/tables/asset-face-audit.table.ts new file mode 100644 index 000000000..4f03c22aa --- /dev/null +++ b/server/src/schema/tables/asset-face-audit.table.ts @@ -0,0 +1,17 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; + +@Table('asset_face_audit') +export class AssetFaceAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + assetFaceId!: string; + + @Column({ type: 'uuid', index: true }) + assetId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 6e45a3a64..5041d945e 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -1,8 +1,11 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { SourceType } from 'src/enum'; import { asset_face_source_type } from 'src/schema/enums'; +import { asset_face_audit } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { + AfterDeleteTrigger, Column, DeleteDateColumn, ForeignKeyColumn, @@ -11,9 +14,17 @@ import { PrimaryGeneratedColumn, Table, Timestamp, + UpdateDateColumn, } from 'src/sql-tools'; @Table({ name: 'asset_face' }) +@UpdatedAtTrigger('asset_face_updatedAt') +@AfterDeleteTrigger({ + scope: 'statement', + function: asset_face_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) // schemaFromDatabase does not preserve column order @Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] }) @Index({ columns: ['personId', 'assetId'] }) @@ -61,4 +72,10 @@ export class AssetFaceTable { @DeleteDateColumn() deletedAt!: Timestamp | null; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn() + updateId!: Generated; } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 4463ab0d7..fb582ab03 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -70,6 +70,7 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.MemoriesV1, SyncRequestType.MemoryToAssetsV1, SyncRequestType.PeopleV1, + SyncRequestType.AssetFacesV1, SyncRequestType.UserMetadataV1, ]; @@ -156,6 +157,7 @@ export class SyncService extends BaseService { [SyncRequestType.StacksV1]: () => this.syncStackV1(response, checkpointMap, auth), [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(response, checkpointMap, auth, session.id), [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(response, checkpointMap, auth), + [SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(response, checkpointMap, auth), [SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(response, checkpointMap, auth), }; @@ -606,6 +608,20 @@ export class SyncService extends BaseService { } } + private async syncAssetFacesV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deleteType = SyncEntityType.AssetFaceDeleteV1; + const deletes = this.syncRepository.assetFace.getDeletes(auth.user.id, checkpointMap[deleteType]); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.AssetFaceV1; + const upserts = this.syncRepository.assetFace.getUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async syncUserMetadataV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { const deleteType = SyncEntityType.UserMetadataDeleteV1; const deletes = this.syncRepository.userMetadata.getDeletes(auth.user.id, checkpointMap[deleteType]); diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index beecf7c69..f655a3944 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -23,6 +23,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' }, deletedAt: new Date(), + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), primaryFace1: Object.freeze({ id: 'assetFaceId2', @@ -39,6 +41,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' }, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), mergeFace1: Object.freeze({ id: 'assetFaceId3', @@ -55,6 +59,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' }, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), noPerson1: Object.freeze({ id: 'assetFaceId8', @@ -71,6 +77,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' }, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), noPerson2: Object.freeze({ id: 'assetFaceId9', @@ -87,6 +95,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' }, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), fromExif1: Object.freeze({ id: 'assetFaceId9', @@ -102,6 +112,8 @@ export const faceStub = { imageWidth: 400, sourceType: SourceType.Exif, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), fromExif2: Object.freeze({ id: 'assetFaceId9', @@ -117,6 +129,8 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.Exif, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), withBirthDate: Object.freeze({ id: 'assetFaceId10', @@ -132,5 +146,7 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MachineLearning, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), }; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 4d13264fa..d6038b6b8 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -154,6 +154,12 @@ export class MediumTestContext { return { asset, result }; } + async newAssetFace(dto: Partial> & { assetId: string }) { + const assetFace = mediumFactory.assetFaceInsert(dto); + const result = await this.get(PersonRepository).createAssetFace(assetFace); + return { assetFace, result }; + } + async newMemory(dto: Partial> = {}) { const memory = mediumFactory.memoryInsert(dto); const result = await this.get(MemoryRepository).create(memory, new Set()); diff --git a/server/test/medium/specs/sync/sync-asset-face.spec.ts b/server/test/medium/specs/sync/sync-asset-face.spec.ts new file mode 100644 index 000000000..68d3007c5 --- /dev/null +++ b/server/test/medium/specs/sync/sync-asset-face.spec.ts @@ -0,0 +1,92 @@ +import { Kysely } from 'kysely'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.AssetFaceV1, () => { + 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, personId: person.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: assetFace.id, + assetId: asset.id, + personId: person.id, + imageWidth: assetFace.imageWidth, + imageHeight: assetFace.imageHeight, + boundingBoxX1: assetFace.boundingBoxX1, + boundingBoxY1: assetFace.boundingBoxY1, + boundingBoxX2: assetFace.boundingBoxX2, + boundingBoxY2: assetFace.boundingBoxY2, + sourceType: assetFace.sourceType, + }), + type: 'AssetFaceV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.AssetFacesV1])).resolves.toEqual([]); + }); + + 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 { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); + await personRepo.deleteAssetFace(assetFace.id); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + assetFaceId: assetFace.id, + }, + type: 'AssetFaceDeleteV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.AssetFacesV1])).resolves.toEqual([]); + }); + + it('should not sync an asset face or asset face delete for an unrelated user', 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 { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); + const auth2 = factory.auth({ session, user: user2 }); + + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV1])).toHaveLength(1); + expect(await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1])).toHaveLength(0); + + await personRepo.deleteAssetFace(assetFace.id); + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV1])).toHaveLength(1); + expect(await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1])).toHaveLength(0); + }); +}); diff --git a/server/test/medium/specs/sync/sync-person.spec.ts b/server/test/medium/specs/sync/sync-person.spec.ts index 807e41894..fbf401e37 100644 --- a/server/test/medium/specs/sync/sync-person.spec.ts +++ b/server/test/medium/specs/sync/sync-person.spec.ts @@ -31,7 +31,6 @@ describe(SyncEntityType.PersonV1, () => { data: expect.objectContaining({ id: person.id, name: person.name, - thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, birthDate: person.birthDate, faceAssetId: person.faceAssetId,