This commit is contained in:
Yaros 2026-08-15 11:39:41 +02:00 committed by GitHub
commit bbaed6d3e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 99 additions and 1 deletions

View file

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

View file

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

View file

@ -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<boolean>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

View file

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

View file

@ -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<DB>;
@ -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, () => {

View file

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