feat: asset face sync (#20048)

* chore: remove thumbnailPath from person sync dto

* feat: asset face sync
This commit is contained in:
Zack Pollard 2025-07-22 02:31:45 +01:00 committed by GitHub
parent 826eaedae6
commit df318ac641
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 407 additions and 11 deletions

View file

@ -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),

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -13788,6 +13788,65 @@
],
"type": "object"
},
"SyncAssetFaceDeleteV1": {
"properties": {
"assetFaceId": {
"type": "string"
}
},
"required": [
"assetFaceId"
],
"type": "object"
},
"SyncAssetFaceV1": {
"properties": {
"assetId": {
"type": "string"
},
"boundingBoxX1": {
"type": "number"
},
"boundingBoxX2": {
"type": "number"
},
"boundingBoxY1": {
"type": "number"
},
"boundingBoxY2": {
"type": "number"
},
"id": {
"type": "string"
},
"imageHeight": {
"type": "number"
},
"imageWidth": {
"type": "number"
},
"personId": {
"nullable": true,
"type": "string"
},
"sourceType": {
"type": "string"
}
},
"required": [
"assetId",
"boundingBoxX1",
"boundingBoxX2",
"boundingBoxY1",
"boundingBoxY2",
"id",
"imageHeight",
"imageWidth",
"personId",
"sourceType"
],
"type": "object"
},
"SyncAssetV1": {
"properties": {
"checksum": {
@ -13912,6 +13971,8 @@
"StackDeleteV1",
"PersonV1",
"PersonDeleteV1",
"AssetFaceV1",
"AssetFaceDeleteV1",
"UserMetadataV1",
"UserMetadataDeleteV1",
"SyncAckV1",
@ -14109,9 +14170,6 @@
"ownerId": {
"type": "string"
},
"thumbnailPath": {
"type": "string"
},
"updatedAt": {
"format": "date-time",
"type": "string"
@ -14127,7 +14185,6 @@
"isHidden",
"name",
"ownerId",
"thumbnailPath",
"updatedAt"
],
"type": "object"
@ -14150,6 +14207,7 @@
"StacksV1",
"UsersV1",
"PeopleV1",
"AssetFacesV1",
"UserMetadataV1"
],
"type": "string"

View file

@ -4125,6 +4125,8 @@ export enum SyncEntityType {
StackDeleteV1 = "StackDeleteV1",
PersonV1 = "PersonV1",
PersonDeleteV1 = "PersonDeleteV1",
AssetFaceV1 = "AssetFaceV1",
AssetFaceDeleteV1 = "AssetFaceDeleteV1",
UserMetadataV1 = "UserMetadataV1",
UserMetadataDeleteV1 = "UserMetadataDeleteV1",
SyncAckV1 = "SyncAckV1",
@ -4147,6 +4149,7 @@ export enum SyncRequestType {
StacksV1 = "StacksV1",
UsersV1 = "UsersV1",
PeopleV1 = "PeopleV1",
AssetFacesV1 = "AssetFacesV1",
UserMetadataV1 = "UserMetadataV1"
}
export enum TranscodeHWAccel {

View file

@ -272,6 +272,8 @@ export type AssetFace = {
personId: string | null;
sourceType: SourceType;
person?: Person | null;
updatedAt: Date;
updateId: string;
};
const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;

View file

@ -245,7 +245,6 @@ export class SyncPersonV1 {
ownerId!: string;
name!: string;
birthDate!: Date | null;
thumbnailPath!: string;
isHidden!: boolean;
isFavorite!: boolean;
color!: string | null;
@ -257,6 +256,25 @@ export class SyncPersonDeleteV1 {
personId!: string;
}
@ExtraModel()
export class SyncAssetFaceV1 {
id!: string;
assetId!: string;
personId!: string | null;
imageWidth!: number;
imageHeight!: number;
boundingBoxX1!: number;
boundingBoxY1!: number;
boundingBoxX2!: number;
boundingBoxY2!: number;
sourceType!: string;
}
@ExtraModel()
export class SyncAssetFaceDeleteV1 {
assetFaceId!: string;
}
@ExtraModel()
export class SyncUserMetadataV1 {
userId!: string;
@ -312,6 +330,8 @@ export type SyncItem = {
[SyncEntityType.PartnerStackV1]: SyncStackV1;
[SyncEntityType.PersonV1]: SyncPersonV1;
[SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1;
[SyncEntityType.AssetFaceV1]: SyncAssetFaceV1;
[SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1;
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
[SyncEntityType.SyncAckV1]: SyncAckV1;

View file

@ -568,6 +568,7 @@ export enum SyncRequestType {
StacksV1 = 'StacksV1',
UsersV1 = 'UsersV1',
PeopleV1 = 'PeopleV1',
AssetFacesV1 = 'AssetFacesV1',
UserMetadataV1 = 'UserMetadataV1',
}
@ -619,6 +620,9 @@ export enum SyncEntityType {
PersonV1 = 'PersonV1',
PersonDeleteV1 = 'PersonDeleteV1',
AssetFaceV1 = 'AssetFaceV1',
AssetFaceDeleteV1 = 'AssetFaceDeleteV1',
UserMetadataV1 = 'UserMetadataV1',
UserMetadataDeleteV1 = 'UserMetadataDeleteV1',

View file

@ -409,6 +409,41 @@ where
order by
"updateId" asc
-- SyncRepository.assetFace.getDeletes
select
"asset_face_audit"."id",
"assetFaceId"
from
"asset_face_audit"
left join "asset" on "asset"."id" = "asset_face_audit"."assetId"
where
"asset"."ownerId" = $1
and "asset_face_audit"."deletedAt" < now() - interval '1 millisecond'
order by
"asset_face_audit"."id" asc
-- SyncRepository.assetFace.getUpserts
select
"asset_face"."id",
"assetId",
"personId",
"imageWidth",
"imageHeight",
"boundingBoxX1",
"boundingBoxY1",
"boundingBoxX2",
"boundingBoxY2",
"sourceType",
"asset_face"."updateId"
from
"asset_face"
left join "asset" on "asset"."id" = "asset_face"."assetId"
where
"asset_face"."updatedAt" < now() - interval '1 millisecond'
and "asset"."ownerId" = $1
order by
"asset_face"."updateId" asc
-- SyncRepository.memory.getDeletes
select
"id",
@ -779,7 +814,6 @@ select
"ownerId",
"name",
"birthDate",
"thumbnailPath",
"isHidden",
"isFavorite",
"color",

View file

@ -17,7 +17,8 @@ type AuditTables =
| 'memory_asset_audit'
| 'stack_audit'
| 'person_audit'
| 'user_metadata_audit';
| 'user_metadata_audit'
| 'asset_face_audit';
type UpsertTables =
| 'user'
| 'partner'
@ -29,7 +30,8 @@ type UpsertTables =
| 'memory_asset'
| 'stack'
| 'person'
| 'user_metadata';
| 'user_metadata'
| 'asset_face';
@Injectable()
export class SyncRepository {
@ -40,6 +42,7 @@ export class SyncRepository {
albumUser: AlbumUserSync;
asset: AssetSync;
assetExif: AssetExifSync;
assetFace: AssetFaceSync;
memory: MemorySync;
memoryToAsset: MemoryToAssetSync;
partner: PartnerSync;
@ -59,6 +62,7 @@ export class SyncRepository {
this.albumUser = new AlbumUserSync(this.db);
this.asset = new AssetSync(this.db);
this.assetExif = new AssetExifSync(this.db);
this.assetFace = new AssetFaceSync(this.db);
this.memory = new MemorySync(this.db);
this.memoryToAsset = new MemoryToAssetSync(this.db);
this.partner = new PartnerSync(this.db);
@ -385,7 +389,6 @@ class PersonSync extends BaseSync {
'ownerId',
'name',
'birthDate',
'thumbnailPath',
'isHidden',
'isFavorite',
'color',
@ -398,6 +401,46 @@ class PersonSync extends BaseSync {
}
}
class AssetFaceSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('asset_face_audit')
.select(['asset_face_audit.id', 'assetFaceId'])
.orderBy('asset_face_audit.id', 'asc')
.leftJoin('asset', 'asset.id', 'asset_face_audit.assetId')
.where('asset.ownerId', '=', userId)
.where('asset_face_audit.deletedAt', '<', sql.raw<Date>("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<Date>("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) {

View file

@ -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`,
});

View file

@ -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;

View file

@ -0,0 +1,52 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}

View file

@ -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<string>;
@Column({ type: 'uuid', index: true })
assetFaceId!: string;
@Column({ type: 'uuid', index: true })
assetId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
}

View file

@ -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<Timestamp>;
@UpdateIdColumn()
updateId!: Generated<string>;
}

View file

@ -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]);

View file

@ -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',
}),
};

View file

@ -154,6 +154,12 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { asset, result };
}
async newAssetFace(dto: Partial<Insertable<AssetFace>> & { assetId: string }) {
const assetFace = mediumFactory.assetFaceInsert(dto);
const result = await this.get(PersonRepository).createAssetFace(assetFace);
return { assetFace, result };
}
async newMemory(dto: Partial<Insertable<MemoryTable>> = {}) {
const memory = mediumFactory.memoryInsert(dto);
const result = await this.get(MemoryRepository).create(memory, new Set<string>());

View file

@ -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<DB>;
const setup = async (db?: Kysely<DB>) => {
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);
});
});

View file

@ -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,