From 2db907239f8f1127d472263ba3860338218c95ea Mon Sep 17 00:00:00 2001 From: Yaros Date: Wed, 24 Jun 2026 13:18:42 +0200 Subject: [PATCH 1/9] fix: update ocr & faces after asset edit --- .../domain/services/sync_stream.service.dart | 18 +++++ .../repositories/sync_stream.repository.dart | 65 +++++++++++++++++++ mobile/lib/providers/websocket.provider.dart | 28 +++++++- mobile/lib/services/api.service.dart | 2 + 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 5a197dc88a..80a9dd592a 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -446,6 +446,7 @@ class SyncStreamService { await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit'); await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit'); + await _refreshAssetOcrAndFaces(asset.id); _logger.info( 'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits', @@ -484,6 +485,7 @@ class SyncStreamService { await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit'); await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit'); + await _refreshAssetOcrAndFaces(asset.id); _logger.info( 'Successfully processed AssetEditReadyV2 event for asset ${asset.id} with ${assetEdits.length} edits', @@ -493,6 +495,22 @@ class SyncStreamService { } } + Future _refreshAssetOcrAndFaces(String assetId) async { + try { + final ocr = await _api.assetsApi.getAssetOcr(assetId); + await _syncStreamRepository.replaceAssetOcr(assetId, ocr ?? const []); + } catch (error, stackTrace) { + _logger.severe("Error refreshing OCR for asset $assetId", error, stackTrace); + } + + try { + final faces = await _api.facesApi.getFaces(assetId); + await _syncStreamRepository.replaceAssetFaces(assetId, faces ?? const []); + } catch (error, stackTrace) { + _logger.severe("Error refreshing faces for asset $assetId", error, stackTrace); + } + } + Future _handleRemoteDeleted(Iterable remoteIds) async { if (remoteIds.isEmpty) { return Future.value(); diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 8de76f1412..a4ddac8edd 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -896,6 +896,71 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } + /// Replaces all OCR rows for [assetId] with [data] (e.g. after an asset edit re-runs OCR). + Future replaceAssetOcr(String assetId, Iterable data) async { + try { + await _db.batch((batch) { + batch.deleteWhere(_db.assetOcrEntity, (row) => row.assetId.equals(assetId)); + + for (final ocr in data) { + batch.insert( + _db.assetOcrEntity, + AssetOcrEntityCompanion( + id: Value(ocr.id), + assetId: Value(ocr.assetId), + recognizedText: Value(ocr.text), + x1: Value(ocr.x1), + y1: Value(ocr.y1), + x2: Value(ocr.x2), + y2: Value(ocr.y2), + x3: Value(ocr.x3), + y3: Value(ocr.y3), + x4: Value(ocr.x4), + y4: Value(ocr.y4), + boxScore: Value(ocr.boxScore), + textScore: Value(ocr.textScore), + isVisible: const Value(true), + ), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: replaceAssetOcr', error, stack); + rethrow; + } + } + + Future replaceAssetFaces(String assetId, Iterable data) async { + try { + await _db.batch((batch) { + batch.deleteWhere(_db.assetFaceEntity, (row) => row.assetId.equals(assetId)); + + for (final face in data) { + batch.insert( + _db.assetFaceEntity, + AssetFaceEntityCompanion( + id: Value(face.id), + assetId: Value(assetId), + personId: Value(face.person?.id), + imageWidth: Value(face.imageWidth), + imageHeight: Value(face.imageHeight), + boundingBoxX1: Value(face.boundingBoxX1), + boundingBoxY1: Value(face.boundingBoxY1), + boundingBoxX2: Value(face.boundingBoxX2), + boundingBoxY2: Value(face.boundingBoxY2), + sourceType: Value(face.sourceType.orElse(null)?.value ?? SourceType.machineLearning.value), + isVisible: const Value(true), + deletedAt: const Value(null), + ), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: replaceAssetFaces', error, stack); + rethrow; + } + } + Future pruneAssets() async { try { await _db.transaction(() async { diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 8d9bd5bfe3..e9da5f7090 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/debounce.dart'; @@ -181,11 +182,34 @@ class WebsocketNotifier extends StateNotifier { } void _handleSyncAssetEditReadyV1(dynamic data) { - unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data)); + final assetId = _assetIdFromEditReady(data); + unawaited( + _ref.read(backgroundSyncProvider).syncWebsocketEditV1(data).whenComplete(() => _onAssetEditApplied(assetId)), + ); } void _handleSyncAssetEditReadyV2(dynamic data) { - unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data)); + final assetId = _assetIdFromEditReady(data); + unawaited( + _ref.read(backgroundSyncProvider).syncWebsocketEditV2(data).whenComplete(() => _onAssetEditApplied(assetId)), + ); + } + + String? _assetIdFromEditReady(dynamic data) { + if (data is Map && data['asset'] is Map) { + final id = (data['asset'] as Map)['id']; + return id is String ? id : null; + } + return null; + } + + /// The edit handler refreshes OCR/faces in the drift DB from a background isolate, + /// so the main-isolate UI providers must be invalidated here to re-read the new data. + void _onAssetEditApplied(String? assetId) { + if (assetId == null) { + return; + } + _ref.invalidate(ocrAssetProvider(assetId)); } void _processBatchedAssetUploadReadyV1() { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 59ef935f2f..2e5b8c0029 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -36,6 +36,7 @@ class ApiService { late MemoriesApi memoriesApi; late SessionsApi sessionsApi; late TagsApi tagsApi; + late FacesApi facesApi; ApiService() { // The below line ensures that the api clients are initialized when the service is instantiated @@ -77,6 +78,7 @@ class ApiService { memoriesApi = MemoriesApi(_apiClient); sessionsApi = SessionsApi(_apiClient); tagsApi = TagsApi(_apiClient); + facesApi = FacesApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { From 6c8928c2f85da5aea783189e608b73e3399e66a5 Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 3 Aug 2026 17:41:18 +0200 Subject: [PATCH 2/9] chore: remove client-side refresh --- .../domain/services/sync_stream.service.dart | 18 ----- .../repositories/sync_stream.repository.dart | 65 ------------------- mobile/lib/providers/websocket.provider.dart | 28 +------- mobile/lib/services/api.service.dart | 2 - 4 files changed, 2 insertions(+), 111 deletions(-) diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 80a9dd592a..5a197dc88a 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -446,7 +446,6 @@ class SyncStreamService { await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit'); await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit'); - await _refreshAssetOcrAndFaces(asset.id); _logger.info( 'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits', @@ -485,7 +484,6 @@ class SyncStreamService { await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit'); await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit'); - await _refreshAssetOcrAndFaces(asset.id); _logger.info( 'Successfully processed AssetEditReadyV2 event for asset ${asset.id} with ${assetEdits.length} edits', @@ -495,22 +493,6 @@ class SyncStreamService { } } - Future _refreshAssetOcrAndFaces(String assetId) async { - try { - final ocr = await _api.assetsApi.getAssetOcr(assetId); - await _syncStreamRepository.replaceAssetOcr(assetId, ocr ?? const []); - } catch (error, stackTrace) { - _logger.severe("Error refreshing OCR for asset $assetId", error, stackTrace); - } - - try { - final faces = await _api.facesApi.getFaces(assetId); - await _syncStreamRepository.replaceAssetFaces(assetId, faces ?? const []); - } catch (error, stackTrace) { - _logger.severe("Error refreshing faces for asset $assetId", error, stackTrace); - } - } - Future _handleRemoteDeleted(Iterable remoteIds) async { if (remoteIds.isEmpty) { return Future.value(); diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index a4ddac8edd..8de76f1412 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -896,71 +896,6 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } - /// Replaces all OCR rows for [assetId] with [data] (e.g. after an asset edit re-runs OCR). - Future replaceAssetOcr(String assetId, Iterable data) async { - try { - await _db.batch((batch) { - batch.deleteWhere(_db.assetOcrEntity, (row) => row.assetId.equals(assetId)); - - for (final ocr in data) { - batch.insert( - _db.assetOcrEntity, - AssetOcrEntityCompanion( - id: Value(ocr.id), - assetId: Value(ocr.assetId), - recognizedText: Value(ocr.text), - x1: Value(ocr.x1), - y1: Value(ocr.y1), - x2: Value(ocr.x2), - y2: Value(ocr.y2), - x3: Value(ocr.x3), - y3: Value(ocr.y3), - x4: Value(ocr.x4), - y4: Value(ocr.y4), - boxScore: Value(ocr.boxScore), - textScore: Value(ocr.textScore), - isVisible: const Value(true), - ), - ); - } - }); - } catch (error, stack) { - _logger.severe('Error: replaceAssetOcr', error, stack); - rethrow; - } - } - - Future replaceAssetFaces(String assetId, Iterable data) async { - try { - await _db.batch((batch) { - batch.deleteWhere(_db.assetFaceEntity, (row) => row.assetId.equals(assetId)); - - for (final face in data) { - batch.insert( - _db.assetFaceEntity, - AssetFaceEntityCompanion( - id: Value(face.id), - assetId: Value(assetId), - personId: Value(face.person?.id), - imageWidth: Value(face.imageWidth), - imageHeight: Value(face.imageHeight), - boundingBoxX1: Value(face.boundingBoxX1), - boundingBoxY1: Value(face.boundingBoxY1), - boundingBoxX2: Value(face.boundingBoxX2), - boundingBoxY2: Value(face.boundingBoxY2), - sourceType: Value(face.sourceType.orElse(null)?.value ?? SourceType.machineLearning.value), - isVisible: const Value(true), - deletedAt: const Value(null), - ), - ); - } - }); - } catch (error, stack) { - _logger.severe('Error: replaceAssetFaces', error, stack); - rethrow; - } - } - Future pruneAssets() async { try { await _db.transaction(() async { diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index e9da5f7090..8d9bd5bfe3 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -7,7 +7,6 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/debounce.dart'; @@ -182,34 +181,11 @@ class WebsocketNotifier extends StateNotifier { } void _handleSyncAssetEditReadyV1(dynamic data) { - final assetId = _assetIdFromEditReady(data); - unawaited( - _ref.read(backgroundSyncProvider).syncWebsocketEditV1(data).whenComplete(() => _onAssetEditApplied(assetId)), - ); + unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data)); } void _handleSyncAssetEditReadyV2(dynamic data) { - final assetId = _assetIdFromEditReady(data); - unawaited( - _ref.read(backgroundSyncProvider).syncWebsocketEditV2(data).whenComplete(() => _onAssetEditApplied(assetId)), - ); - } - - String? _assetIdFromEditReady(dynamic data) { - if (data is Map && data['asset'] is Map) { - final id = (data['asset'] as Map)['id']; - return id is String ? id : null; - } - return null; - } - - /// The edit handler refreshes OCR/faces in the drift DB from a background isolate, - /// so the main-isolate UI providers must be invalidated here to re-read the new data. - void _onAssetEditApplied(String? assetId) { - if (assetId == null) { - return; - } - _ref.invalidate(ocrAssetProvider(assetId)); + unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data)); } void _processBatchedAssetUploadReadyV1() { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 2e5b8c0029..59ef935f2f 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -36,7 +36,6 @@ class ApiService { late MemoriesApi memoriesApi; late SessionsApi sessionsApi; late TagsApi tagsApi; - late FacesApi facesApi; ApiService() { // The below line ensures that the api clients are initialized when the service is instantiated @@ -78,7 +77,6 @@ class ApiService { memoriesApi = MemoriesApi(_apiClient); sessionsApi = SessionsApi(_apiClient); tagsApi = TagsApi(_apiClient); - facesApi = FacesApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { From e1fed803259a9abe59c2d20c54125b4a98460b44 Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 3 Aug 2026 17:42:11 +0200 Subject: [PATCH 3/9] fix(server): sync asset OCR visibility changes --- .../1785771288254-AssetOcrUpdatedAtTrigger.ts | 16 +++++ server/src/schema/tables/asset-ocr.table.ts | 8 ++- .../medium/specs/sync/sync-asset-ocr.spec.ts | 58 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 server/src/schema/migrations/1785771288254-AssetOcrUpdatedAtTrigger.ts diff --git a/server/src/schema/migrations/1785771288254-AssetOcrUpdatedAtTrigger.ts b/server/src/schema/migrations/1785771288254-AssetOcrUpdatedAtTrigger.ts new file mode 100644 index 0000000000..3ad19ead6f --- /dev/null +++ b/server/src/schema/migrations/1785771288254-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/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/sync/sync-asset-ocr.spec.ts b/server/test/medium/specs/sync/sync-asset-ocr.spec.ts index 02d6808ecf..f9e79e2544 100644 --- a/server/test/medium/specs/sync/sync-asset-ocr.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-ocr.spec.ts @@ -2,6 +2,7 @@ import { Kysely } from 'kysely'; import { SyncEntityType, SyncRequestType } from 'src/enum'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { DB } from 'src/schema'; +import { factory } from 'test/small.factory'; import { SyncTestContext } from 'test/medium.factory'; import { getKyselyDB } from 'test/utils'; @@ -303,6 +304,63 @@ 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); + + // Mirrors MediaService on edit/crop: an in-place UPDATE ... SET isVisible without deleting the row. + // This only reaches sync clients if the asset_ocr_updatedAt trigger bumps updateId. + 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, () => { From 90821240de21fe3f042baa15a398c161f66d357c Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 3 Aug 2026 17:57:42 +0200 Subject: [PATCH 4/9] chore: prettier format --- server/test/medium/specs/sync/sync-asset-ocr.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f9e79e2544..ab2fa89544 100644 --- a/server/test/medium/specs/sync/sync-asset-ocr.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-ocr.spec.ts @@ -2,8 +2,8 @@ import { Kysely } from 'kysely'; import { SyncEntityType, SyncRequestType } from 'src/enum'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { DB } from 'src/schema'; -import { factory } from 'test/small.factory'; import { SyncTestContext } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; From 81f3575fb7c7abaa45155dc863dcfef38e94f35b Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 3 Aug 2026 18:11:56 +0200 Subject: [PATCH 5/9] test: missing updatedAt --- server/test/small.factory.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index ae83727d70..7ef41c2f5e 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -210,10 +210,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, From 0d602596efafb7272bc8683a2f01afb40d8f3477 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sat, 8 Aug 2026 16:50:41 +0200 Subject: [PATCH 6/9] chore: remove comment --- server/test/medium/specs/sync/sync-asset-ocr.spec.ts | 2 -- 1 file changed, 2 deletions(-) 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 ab2fa89544..7d43bc6eb1 100644 --- a/server/test/medium/specs/sync/sync-asset-ocr.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-ocr.spec.ts @@ -336,8 +336,6 @@ describe(SyncEntityType.AssetOcrV1, () => { const ocrId = (response[0] as { data: { id: string } }).data.id; await ctx.syncAckAll(auth, response); - // Mirrors MediaService on edit/crop: an in-place UPDATE ... SET isVisible without deleting the row. - // This only reaches sync clients if the asset_ocr_updatedAt trigger bumps updateId. await ocrRepo.updateOcrVisibilities( asset.id, [], From a80acf4e333c7a57a3ae6f96ed32c09c1b92c417 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sat, 8 Aug 2026 16:50:46 +0200 Subject: [PATCH 7/9] chore: fix test --- server/test/medium/specs/services/ocr.service.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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, From 2055f7faf5d478a02f9b04ea6f0db904c0a1dd15 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Tue, 11 Aug 2026 12:43:01 +0200 Subject: [PATCH 8/9] chore: update migration timestamp --- ...atedAtTrigger.ts => 1786444916551-AssetOcrUpdatedAtTrigger.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/src/schema/migrations/{1785771288254-AssetOcrUpdatedAtTrigger.ts => 1786444916551-AssetOcrUpdatedAtTrigger.ts} (100%) diff --git a/server/src/schema/migrations/1785771288254-AssetOcrUpdatedAtTrigger.ts b/server/src/schema/migrations/1786444916551-AssetOcrUpdatedAtTrigger.ts similarity index 100% rename from server/src/schema/migrations/1785771288254-AssetOcrUpdatedAtTrigger.ts rename to server/src/schema/migrations/1786444916551-AssetOcrUpdatedAtTrigger.ts From deb9bb75556a86b7a54b5b8ffbe3efd2466f7e89 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sat, 15 Aug 2026 11:39:33 +0200 Subject: [PATCH 9/9] chore(server): AssetOcrSyncReset --- .../migrations/1786786701092-AssetOcrSyncReset.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 server/src/schema/migrations/1786786701092-AssetOcrSyncReset.ts 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 +}