diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1beaec0ae..3ba9327ec 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 399896172..e77166482 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 0edc2638b..8bdda3a32 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index ecaadc9c3..60cc4da6c 100644 Binary files a/mobile/openapi/lib/model/sync_entity_type.dart and b/mobile/openapi/lib/model/sync_entity_type.dart differ diff --git a/mobile/openapi/lib/model/sync_person_delete_v1.dart b/mobile/openapi/lib/model/sync_person_delete_v1.dart new file mode 100644 index 000000000..002f5c5b8 Binary files /dev/null and b/mobile/openapi/lib/model/sync_person_delete_v1.dart differ diff --git a/mobile/openapi/lib/model/sync_person_v1.dart b/mobile/openapi/lib/model/sync_person_v1.dart new file mode 100644 index 000000000..e86c22f64 Binary files /dev/null and b/mobile/openapi/lib/model/sync_person_v1.dart differ diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index c13d791d8..0b121d96c 100644 Binary files a/mobile/openapi/lib/model/sync_request_type.dart and b/mobile/openapi/lib/model/sync_request_type.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1624d0ed7..fbd7165dd 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13834,6 +13834,8 @@ "MemoryToAssetDeleteV1", "StackV1", "StackDeleteV1", + "PersonV1", + "PersonDeleteV1", "SyncAckV1" ], "type": "string" @@ -13983,6 +13985,74 @@ ], "type": "object" }, + "SyncPersonDeleteV1": { + "properties": { + "personId": { + "type": "string" + } + }, + "required": [ + "personId" + ], + "type": "object" + }, + "SyncPersonV1": { + "properties": { + "birthDate": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "color": { + "nullable": true, + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "faceAssetId": { + "nullable": true, + "type": "string" + }, + "id": { + "type": "string" + }, + "isFavorite": { + "type": "boolean" + }, + "isHidden": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "thumbnailPath": { + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "birthDate", + "color", + "createdAt", + "faceAssetId", + "id", + "isFavorite", + "isHidden", + "name", + "ownerId", + "thumbnailPath", + "updatedAt" + ], + "type": "object" + }, "SyncRequestType": { "enum": [ "AlbumsV1", @@ -13999,7 +14069,8 @@ "PartnerAssetExifsV1", "PartnerStacksV1", "StacksV1", - "UsersV1" + "UsersV1", + "PeopleV1" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 55991b859..c869510b6 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4095,6 +4095,8 @@ export enum SyncEntityType { MemoryToAssetDeleteV1 = "MemoryToAssetDeleteV1", StackV1 = "StackV1", StackDeleteV1 = "StackDeleteV1", + PersonV1 = "PersonV1", + PersonDeleteV1 = "PersonDeleteV1", SyncAckV1 = "SyncAckV1" } export enum SyncRequestType { @@ -4112,7 +4114,8 @@ export enum SyncRequestType { PartnerAssetExifsV1 = "PartnerAssetExifsV1", PartnerStacksV1 = "PartnerStacksV1", StacksV1 = "StacksV1", - UsersV1 = "UsersV1" + UsersV1 = "UsersV1", + PeopleV1 = "PeopleV1" } export enum TranscodeHWAccel { Nvenc = "nvenc", diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index db1fd2941..53b004146 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -233,6 +233,26 @@ export class SyncStackDeleteV1 { stackId!: string; } +@ExtraModel() +export class SyncPersonV1 { + id!: string; + createdAt!: Date; + updatedAt!: Date; + ownerId!: string; + name!: string; + birthDate!: Date | null; + thumbnailPath!: string; + isHidden!: boolean; + isFavorite!: boolean; + color!: string | null; + faceAssetId!: string | null; +} + +@ExtraModel() +export class SyncPersonDeleteV1 { + personId!: string; +} + @ExtraModel() export class SyncAckV1 {} @@ -270,6 +290,8 @@ export type SyncItem = { [SyncEntityType.PartnerStackBackfillV1]: SyncStackV1; [SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1; [SyncEntityType.PartnerStackV1]: SyncStackV1; + [SyncEntityType.PersonV1]: SyncPersonV1; + [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; [SyncEntityType.SyncAckV1]: SyncAckV1; }; diff --git a/server/src/enum.ts b/server/src/enum.ts index d211420ab..81af35cf0 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -588,6 +588,7 @@ export enum SyncRequestType { PartnerStacksV1 = 'PartnerStacksV1', StacksV1 = 'StacksV1', UsersV1 = 'UsersV1', + PeopleV1 = 'PeopleV1', } export enum SyncEntityType { @@ -635,6 +636,9 @@ export enum SyncEntityType { StackV1 = 'StackV1', StackDeleteV1 = 'StackDeleteV1', + PersonV1 = 'PersonV1', + PersonDeleteV1 = 'PersonDeleteV1', + SyncAckV1 = 'SyncAckV1', } diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 50a89655c..f68be8c83 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -749,6 +749,40 @@ where order by "updateId" asc +-- SyncRepository.people.getDeletes +select + "id", + "personId" +from + "person_audit" +where + "ownerId" = $1 + and "deletedAt" < now() - interval '1 millisecond' +order by + "id" asc + +-- SyncRepository.people.getUpserts +select + "id", + "createdAt", + "updatedAt", + "ownerId", + "name", + "birthDate", + "thumbnailPath", + "isHidden", + "isFavorite", + "color", + "updateId", + "faceAssetId" +from + "person" +where + "ownerId" = $1 + and "updatedAt" < now() - interval '1 millisecond' +order by + "updateId" asc + -- SyncRepository.stack.getDeletes select "id", diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 699aaec83..3bc09c97e 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -15,7 +15,8 @@ type AuditTables = | 'album_assets_audit' | 'memories_audit' | 'memory_assets_audit' - | 'stacks_audit'; + | 'stacks_audit' + | 'person_audit'; type UpsertTables = | 'users' | 'partners' @@ -25,7 +26,8 @@ type UpsertTables = | 'albums_shared_users_users' | 'memories' | 'memories_assets_assets' - | 'asset_stack'; + | 'asset_stack' + | 'person'; @Injectable() export class SyncRepository { @@ -42,6 +44,7 @@ export class SyncRepository { partnerAsset: PartnerAssetsSync; partnerAssetExif: PartnerAssetExifsSync; partnerStack: PartnerStackSync; + people: PersonSync; stack: StackSync; user: UserSync; @@ -59,6 +62,7 @@ export class SyncRepository { this.partnerAsset = new PartnerAssetsSync(this.db); this.partnerAssetExif = new PartnerAssetExifsSync(this.db); this.partnerStack = new PartnerStackSync(this.db); + this.people = new PersonSync(this.db); this.stack = new StackSync(this.db); this.user = new UserSync(this.db); } @@ -357,6 +361,41 @@ class AssetSync extends BaseSync { } } +class PersonSync extends BaseSync { + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('person_audit') + .select(['id', 'personId']) + .where('ownerId', '=', userId) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('person') + .select([ + 'id', + 'createdAt', + 'updatedAt', + 'ownerId', + 'name', + 'birthDate', + 'thumbnailPath', + 'isHidden', + 'isFavorite', + 'color', + 'updateId', + 'faceAssetId', + ]) + .where('ownerId', '=', userId) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .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 1b7a4f884..424ceb0d3 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -203,3 +203,16 @@ export const stacks_delete_audit = registerFunction({ RETURN NULL; END`, }); + +export const person_delete_audit = registerFunction({ + name: 'person_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO person_audit ("personId", "ownerId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END`, +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 6512ccc22..f564e8c7f 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -11,6 +11,7 @@ import { memories_delete_audit, memory_assets_delete_audit, partners_delete_audit, + person_delete_audit, stacks_delete_audit, updated_at, users_delete_audit, @@ -42,6 +43,7 @@ import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-coun import { NotificationTable } from 'src/schema/tables/notification.table'; import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; +import { PersonAuditTable } from 'src/schema/tables/person-audit.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; @@ -92,6 +94,7 @@ export class ImmichDatabase { PartnerAuditTable, PartnerTable, PersonTable, + PersonAuditTable, SessionTable, SharedLinkAssetTable, SharedLinkTable, @@ -124,6 +127,7 @@ export class ImmichDatabase { memories_delete_audit, memory_assets_delete_audit, stacks_delete_audit, + person_delete_audit, ]; enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum]; @@ -166,6 +170,7 @@ export interface DB { partners_audit: PartnerAuditTable; partners: PartnerTable; person: PersonTable; + person_audit: PersonAuditTable; sessions: SessionTable; session_sync_checkpoints: SessionSyncCheckpointTable; shared_link__asset: SharedLinkAssetTable; diff --git a/server/src/schema/migrations/1752152941084-PeopleAuditTable.ts b/server/src/schema/migrations/1752152941084-PeopleAuditTable.ts new file mode 100644 index 000000000..3a3da0ded --- /dev/null +++ b/server/src/schema/migrations/1752152941084-PeopleAuditTable.ts @@ -0,0 +1,41 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION person_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO person_audit ("personId", "ownerId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "person_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "personId" uuid NOT NULL, + "ownerId" uuid NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "PK_46c1ad23490b9312ffaa052aa59" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "IDX_person_audit_person_id" ON "person_audit" ("personId");`.execute(db); + await sql`CREATE INDEX "IDX_person_audit_owner_id" ON "person_audit" ("ownerId");`.execute(db); + await sql`CREATE INDEX "IDX_person_audit_deleted_at" ON "person_audit" ("deletedAt");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_delete_audit" + AFTER DELETE ON "person" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION person_delete_audit();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_delete_audit', '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personId\\", \\"ownerId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_delete_audit', '{"type":"trigger","name":"person_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_audit\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION person_delete_audit();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "person_delete_audit" ON "person";`.execute(db); + await sql`DROP TABLE "person_audit";`.execute(db); + await sql`DROP FUNCTION person_delete_audit;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_delete_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_delete_audit';`.execute(db); +} diff --git a/server/src/schema/tables/person-audit.table.ts b/server/src/schema/tables/person-audit.table.ts new file mode 100644 index 000000000..f5790629d --- /dev/null +++ b/server/src/schema/tables/person-audit.table.ts @@ -0,0 +1,17 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; + +@Table('person_audit') +export class PersonAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', indexName: 'IDX_person_audit_person_id' }) + personId!: string; + + @Column({ type: 'uuid', indexName: 'IDX_person_audit_owner_id' }) + ownerId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_person_audit_deleted_at' }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/person.table.ts b/server/src/schema/tables/person.table.ts index 5835b2528..193ecb892 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -1,7 +1,9 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { person_delete_audit } from 'src/schema/functions'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { UserTable } from 'src/schema/tables/user.table'; import { + AfterDeleteTrigger, Check, Column, CreateDateColumn, @@ -15,6 +17,12 @@ import { @Table('person') @UpdatedAtTrigger('person_updated_at') +@AfterDeleteTrigger({ + scope: 'statement', + function: person_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) @Check({ name: 'CHK_b0f82b0ed662bfc24fbb58bb45', expression: `"birthDate" <= CURRENT_DATE` }) export class PersonTable { @PrimaryGeneratedColumn('uuid') diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index e3322de2e..5e9c679d7 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -69,6 +69,7 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.PartnerAssetExifsV1, SyncRequestType.MemoriesV1, SyncRequestType.MemoryToAssetsV1, + SyncRequestType.PeopleV1, ]; const throwSessionRequired = () => { @@ -141,6 +142,7 @@ export class SyncService extends BaseService { [SyncRequestType.MemoryToAssetsV1]: () => this.syncMemoryAssetsV1(response, checkpointMap, auth), [SyncRequestType.StacksV1]: () => this.syncStackV1(response, checkpointMap, auth), [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(response, checkpointMap, auth, sessionId), + [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(response, checkpointMap, auth), }; for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) { @@ -488,7 +490,7 @@ export class SyncService extends BaseService { private async syncMemoriesV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { const deleteType = SyncEntityType.MemoryDeleteV1; - const deletes = this.syncRepository.memory.getDeletes(auth.user.id, checkpointMap[SyncEntityType.MemoryDeleteV1]); + const deletes = this.syncRepository.memory.getDeletes(auth.user.id, checkpointMap[deleteType]); for await (const { id, ...data } of deletes) { send(response, { type: deleteType, ids: [id], data }); } @@ -576,6 +578,20 @@ export class SyncService extends BaseService { } } + private async syncPeopleV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deleteType = SyncEntityType.PersonDeleteV1; + const deletes = this.syncRepository.people.getDeletes(auth.user.id, checkpointMap[deleteType]); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.PersonV1; + const upserts = this.syncRepository.people.getUpserts(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.syncCheckpointRepository.upsertAll([ diff --git a/server/test/medium/specs/sync/sync-person.spec.ts b/server/test/medium/specs/sync/sync-person.spec.ts new file mode 100644 index 000000000..807e41894 --- /dev/null +++ b/server/test/medium/specs/sync/sync-person.spec.ts @@ -0,0 +1,87 @@ +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.PersonV1, () => { + it('should detect and sync the first person', async () => { + const { auth, ctx } = await setup(); + const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: person.id, + name: person.name, + thumbnailPath: person.thumbnailPath, + isHidden: person.isHidden, + birthDate: person.birthDate, + faceAssetId: person.faceAssetId, + isFavorite: person.isFavorite, + ownerId: auth.user.id, + color: person.color, + }), + type: 'PersonV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.PeopleV1])).resolves.toEqual([]); + }); + + it('should detect and sync a deleted person', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + await personRepo.delete([person.id]); + + const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + personId: person.id, + }, + type: 'PersonDeleteV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.PeopleV1])).resolves.toEqual([]); + }); + + it('should not sync a person or person 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 { person } = await ctx.newPerson({ ownerId: user2.id }); + const auth2 = factory.auth({ session, user: user2 }); + + expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toHaveLength(1); + expect(await ctx.syncStream(auth, [SyncRequestType.PeopleV1])).toHaveLength(0); + + await personRepo.delete([person.id]); + expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toHaveLength(1); + expect(await ctx.syncStream(auth, [SyncRequestType.PeopleV1])).toHaveLength(0); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index 8d8d12c54..e12b614f0 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -17,6 +17,9 @@ "skipLibCheck": true, "esModuleInterop": true, "preserveWatchOutput": true, + "paths": { + "src/*": ["./src/*"], + }, "baseUrl": "./", "jsx": "react", "types": ["vitest/globals"],