feat(server): ocr audit table

This commit is contained in:
Yaros
2026-02-25 12:45:56 +01:00
parent 811d3e1c33
commit f706738f93
17 changed files with 306 additions and 13 deletions

View File

@@ -263,6 +263,18 @@ export class SyncAssetOcrV1 {
isVisible!: boolean;
}
@ExtraModel()
export class SyncAssetOcrDeleteV1 {
@ApiProperty({ description: 'Audit row ID of the deleted OCR entry' })
id!: string;
@ApiProperty({ description: 'Original asset ID of the deleted OCR entry' })
assetId!: string;
@ApiProperty({ description: 'Timestamp when the OCR entry was deleted' })
deletedAt!: Date;
}
@ExtraModel()
export class SyncAssetMetadataV1 {
@ApiProperty({ description: 'Asset ID' })
@@ -526,6 +538,7 @@ export type SyncItem = {
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
[SyncEntityType.AssetOcrV1]: SyncAssetOcrV1;
[SyncEntityType.AssetOcrDeleteV1]: SyncAssetOcrDeleteV1;
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;

View File

@@ -749,6 +749,7 @@ export enum SyncEntityType {
AssetMetadataV1 = 'AssetMetadataV1',
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
AssetOcrV1 = 'AssetOcrV1',
AssetOcrDeleteV1 = 'AssetOcrDeleteV1',
PartnerV1 = 'PartnerV1',
PartnerDeleteV1 = 'PartnerDeleteV1',

View File

@@ -776,6 +776,19 @@ class AssetMetadataSync extends BaseSync {
}
class AssetOcrSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
getDeletes(options: SyncQueryOptions, userId: string) {
return this.auditQuery('asset_ocr_audit', options)
.select(['asset_ocr_audit.id', 'assetId', 'deletedAt'])
.leftJoin('asset', 'asset.id', 'asset_ocr_audit.assetId')
.where('asset.ownerId', '=', userId)
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('asset_ocr_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
getUpserts(options: SyncQueryOptions, userId: string) {
return this.upsertQuery('asset_ocr', options)

View File

@@ -280,9 +280,22 @@ export const asset_edit_delete = registerFunction({
UPDATE asset
SET "isEdited" = false
FROM deleted_edit
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
RETURN NULL;
END
`,
});
export const asset_ocr_delete_audit = registerFunction({
name: 'asset_ocr_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO asset_ocr_audit ("assetId")
SELECT "assetId"
FROM OLD;
RETURN NULL;
END`,
});

View File

@@ -7,6 +7,7 @@ import {
asset_delete_audit,
asset_face_audit,
asset_metadata_audit,
asset_ocr_delete_audit,
f_concat_ws,
f_unaccent,
immich_uuid_v7,
@@ -73,6 +74,7 @@ import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { AssetOcrAuditTable } from './tables/asset-ocr-audit.table';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@Database({ name: 'immich' })
@@ -156,6 +158,7 @@ export class ImmichDatabase {
user_metadata_audit,
asset_metadata_audit,
asset_face_audit,
asset_ocr_delete_audit,
];
enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];
@@ -192,6 +195,7 @@ export interface DB {
asset_metadata_audit: AssetMetadataAuditTable;
asset_job_status: AssetJobStatusTable;
asset_ocr: AssetOcrTable;
asset_ocr_audit: AssetOcrAuditTable;
ocr_search: OcrSearchTable;
audit: AuditTable;

View File

@@ -1,11 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_ocr" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
await sql`CREATE INDEX "asset_ocr_updateId_idx" ON "asset_ocr" ("updateId");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "asset_ocr_updateId_idx";`.execute(db);
await sql`ALTER TABLE "asset_ocr" DROP COLUMN "updateId";`.execute(db);
}

View File

@@ -0,0 +1,63 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION asset_edit_delete()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE asset
SET "isEdited" = false
FROM deleted_edit
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION asset_ocr_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO asset_ocr_audit ("assetId")
SELECT "assetId"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`ALTER TABLE "asset_ocr" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
await sql`CREATE INDEX "asset_ocr_updateId_idx" ON "asset_ocr" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "asset_ocr_delete_audit"
AFTER DELETE ON "asset_ocr"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() = 0)
EXECUTE FUNCTION asset_ocr_delete_audit();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_ocr_delete_audit', '{"type":"function","name":"asset_ocr_delete_audit","sql":"CREATE OR REPLACE FUNCTION asset_ocr_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_ocr_audit (\\"assetId\\")\\n SELECT \\"assetId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_ocr_delete_audit', '{"type":"trigger","name":"asset_ocr_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_ocr_delete_audit\\"\\n AFTER DELETE ON \\"asset_ocr\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_ocr_delete_audit();"}'::jsonb);`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"asset_edit_delete","sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\"\\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION public.asset_edit_delete()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE asset
SET "isEdited" = false
FROM deleted_edit
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
RETURN NULL;
END
$function$
`.execute(db);
await sql`DROP TRIGGER "asset_ocr_delete_audit" ON "asset_ocr";`.execute(db);
await sql`DROP INDEX "asset_ocr_updateId_idx";`.execute(db);
await sql`ALTER TABLE "asset_ocr" DROP COLUMN "updateId";`.execute(db);
await sql`DROP FUNCTION asset_ocr_delete_audit;`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\" \\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;","name":"asset_edit_delete","type":"function"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_ocr_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_ocr_delete_audit';`.execute(db);
}

View File

@@ -0,0 +1,14 @@
import { Column, CreateDateColumn, Generated, Table } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
@Table('asset_ocr_audit')
export class AssetOcrAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid', index: true })
assetId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Date;
}

View File

@@ -1,8 +1,22 @@
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools';
import {
AfterDeleteTrigger,
Column,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
} from '@immich/sql-tools';
import { UpdateIdColumn } from 'src/decorators';
import { AssetTable } from 'src/schema/tables/asset.table';
import { asset_ocr_delete_audit } from '../functions';
@Table('asset_ocr')
@AfterDeleteTrigger({
scope: 'statement',
function: asset_ocr_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class AssetOcrTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;

View File

@@ -223,6 +223,7 @@ export class SyncService extends BaseService {
await this.syncRepository.stack.cleanupAuditTable(pruneThreshold);
await this.syncRepository.user.cleanupAuditTable(pruneThreshold);
await this.syncRepository.userMetadata.cleanupAuditTable(pruneThreshold);
await this.syncRepository.assetOcr.cleanupAuditTable(pruneThreshold);
}
private needsFullSync(checkpointMap: CheckpointMap) {
@@ -862,6 +863,16 @@ export class SyncService extends BaseService {
checkpointMap: CheckpointMap,
auth: AuthDto,
) {
const deleteType = SyncEntityType.AssetOcrDeleteV1;
const deletes = this.syncRepository.assetOcr.getDeletes(
{ ...options, ack: checkpointMap[deleteType] },
auth.user.id,
);
for await (const row of deletes) {
send(response, { type: deleteType, ids: [row.id], data: row });
}
const upsertType = SyncEntityType.AssetOcrV1;
const upserts = this.syncRepository.assetOcr.getUpserts(
{ ...options, ack: checkpointMap[upsertType] },