diff --git a/server/src/schema/migrations/1786444916551-AssetOcrUpdatedAtTrigger.ts b/server/src/schema/migrations/1786444916551-AssetOcrUpdatedAtTrigger.ts new file mode 100644 index 0000000000..3ad19ead6f --- /dev/null +++ b/server/src/schema/migrations/1786444916551-AssetOcrUpdatedAtTrigger.ts @@ -0,0 +1,16 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "asset_ocr" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_ocr_updatedAt" + BEFORE UPDATE ON "asset_ocr" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_ocr_updatedAt', '{"type":"trigger","name":"asset_ocr_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"asset_ocr_updatedAt\\"\\n BEFORE UPDATE ON \\"asset_ocr\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "asset_ocr_updatedAt" ON "asset_ocr";`.execute(db); + await sql`ALTER TABLE "asset_ocr" DROP COLUMN "updatedAt";`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_ocr_updatedAt';`.execute(db); +} diff --git a/server/src/schema/migrations/1786786701092-AssetOcrSyncReset.ts b/server/src/schema/migrations/1786786701092-AssetOcrSyncReset.ts new file mode 100644 index 0000000000..767778e6a1 --- /dev/null +++ b/server/src/schema/migrations/1786786701092-AssetOcrSyncReset.ts @@ -0,0 +1,12 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // OCR visibility updates did not bump updateId before the asset_ocr_updatedAt trigger was added, + // so clients never received those changes. There is no way to know which rows were missed, so + // reset the checkpoint to backfill all OCR rows on the next sync. + await sql`DELETE FROM session_sync_checkpoint WHERE type = 'AssetOcrV1'`.execute(db); +} + +export async function down(): Promise { + // Not implemented +} diff --git a/server/src/schema/tables/asset-ocr.table.ts b/server/src/schema/tables/asset-ocr.table.ts index f2c4e40032..80f6c52dae 100644 --- a/server/src/schema/tables/asset-ocr.table.ts +++ b/server/src/schema/tables/asset-ocr.table.ts @@ -5,12 +5,15 @@ import { Generated, PrimaryGeneratedColumn, Table, + Timestamp, + UpdateDateColumn, } from '@immich/sql-tools'; -import { UpdateIdColumn } from 'src/decorators'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { asset_ocr_delete_audit } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; @Table('asset_ocr') +@UpdatedAtTrigger('asset_ocr_updatedAt') @AfterDeleteTrigger({ scope: 'statement', function: asset_ocr_delete_audit, @@ -61,6 +64,9 @@ export class AssetOcrTable { @Column({ type: 'boolean', default: true }) isVisible!: Generated; + @UpdateDateColumn() + updatedAt!: Generated; + @UpdateIdColumn({ index: true }) updateId!: Generated; } diff --git a/server/test/medium/specs/services/ocr.service.spec.ts b/server/test/medium/specs/services/ocr.service.spec.ts index 5f69aea850..efcc7aa136 100644 --- a/server/test/medium/specs/services/ocr.service.spec.ts +++ b/server/test/medium/specs/services/ocr.service.spec.ts @@ -56,6 +56,7 @@ describe(OcrService.name, () => { boxScore: 0.99, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Test OCR', textScore: 0.95, isVisible: true, @@ -107,6 +108,7 @@ describe(OcrService.name, () => { boxScore: 0.7, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'One', textScore: 0.9, isVisible: true, @@ -124,6 +126,7 @@ describe(OcrService.name, () => { boxScore: 0.67, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Two', textScore: 0.89, isVisible: true, @@ -141,6 +144,7 @@ describe(OcrService.name, () => { boxScore: 0.65, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Three', textScore: 0.88, isVisible: true, @@ -158,6 +162,7 @@ describe(OcrService.name, () => { boxScore: 0.62, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Four', textScore: 0.87, isVisible: true, @@ -175,6 +180,7 @@ describe(OcrService.name, () => { boxScore: 0.6, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Five', textScore: 0.86, isVisible: true, diff --git a/server/test/medium/specs/sync/sync-asset-ocr.spec.ts b/server/test/medium/specs/sync/sync-asset-ocr.spec.ts index 02d6808ecf..7d43bc6eb1 100644 --- a/server/test/medium/specs/sync/sync-asset-ocr.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-ocr.spec.ts @@ -3,6 +3,7 @@ import { SyncEntityType, SyncRequestType } from 'src/enum'; import { OcrRepository } from 'src/repositories/ocr.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; @@ -303,6 +304,61 @@ describe(SyncEntityType.AssetOcrV1, () => { await ctx.syncAckAll(auth, updatedResponse); await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetOcrV1]); }); + + it('should sync in-place OCR visibility toggles (e.g. after an asset edit)', async () => { + const { auth, user, ctx } = await setup(); + + const ocrRepo = ctx.get(OcrRepository); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ocrRepo.upsert( + asset.id, + [ + { + assetId: asset.id, + x1: 0.1, + y1: 0.2, + x2: 0.9, + y2: 0.2, + x3: 0.9, + y3: 0.8, + x4: 0.1, + y4: 0.8, + boxScore: 0.95, + textScore: 0.92, + text: 'Hello World', + isVisible: true, + }, + ], + 'Hello World', + ); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]); + const ocrId = (response[0] as { data: { id: string } }).data.id; + await ctx.syncAckAll(auth, response); + + await ocrRepo.updateOcrVisibilities( + asset.id, + [], + [factory.assetOcr({ id: ocrId, assetId: asset.id, text: 'Hello World' })], + ); + + const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]); + expect(updatedResponse).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: ocrId, + assetId: asset.id, + isVisible: false, + }), + type: 'AssetOcrV1', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, updatedResponse); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetOcrV1]); + }); }); describe(SyncEntityType.AssetOcrDeleteV1, () => { diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 545c05e881..14f74d1a25 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -185,10 +185,12 @@ const assetOcrFactory = ( textScore?: number; text?: string; isVisible?: boolean; + updatedAt?: Date; } = {}, ) => ({ id: newUuid(), updateId: newUuidV7(), + updatedAt: newDate(), assetId: newUuid(), x1: 0.1, y1: 0.2,