From d614e47aaee89b30df1066dbd2df0d9dbc01dae5 Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Mon, 7 Sep 2026 03:27:49 +0300 Subject: [PATCH 1/3] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=A1?= =?UTF-8?q?=D7=A4=D7=A8=D7=99=D7=99=D7=94:=20=D7=90=D7=99=D7=9E=D7=95?= =?UTF-8?q?=D7=AA=20=D7=97=D7=9C=D7=A7=D7=99=20=D7=9C=D7=A4=D7=99=20=D7=98?= =?UTF-8?q?=D7=91=D7=9C=D7=90=D7=95=D7=AA=20=D7=95=D7=91=D7=93=D7=99=D7=A7?= =?UTF-8?q?=D7=AA=20=D7=A9=D7=90=D7=A8=20=D7=94=D7=A1=D7=A4=D7=A8=D7=99?= =?UTF-8?q?=D7=99=D7=94=20=D7=90=D7=97=D7=A8=D7=99=20=D7=94-commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit כשהמניפסט נושא hash לכל טבלה, ה-updater מאמת בתוך ה-transaction רק את הטבלאות שה-patch יכול היה לשנות, ולכן שלב 'מאמת את הספרייה המעודכנת' מתקצר מדקות לשניות. הטבלאות שאף צעד בשרשרת לא נגע בהן נבדקות אחרי שהשרשרת נשמרה וחיבור ה-RO נפתח מחדש — מעבר קריאה-בלבד ב-isolate, בלי תור פעולות, בשלב 'verifyDeferred' ('בודק את שאר הספרייה (ניתן להמשיך לקרוא)'). סטייה שמתגלית בבדיקה הזו אינה rollback: העדכון תקין לטבלאות שנגע בהן. היא נרשמת ל-errors.txt ומנותבת ל-needsFullConfirmation עם LibraryMessages.libraryContentDriftAfterUpdate, תוך שימור changedBookIds לאינדוקס. כשל של הבדיקה עצמה נרשם ואינו הופך עדכון תקין לשגיאה. רמז הבתים לכל טבלה נשמר ב-library_update_cache/verify_table_bytes.json למד ההתקדמות בריצות הבאות. מניפסט בלי מפות — התנהגות זהה לקודם. דורש otzaria_library_updater 0.4.0 (Otzaria/otzaria_library_updater — PR מקושר) ו-SeforimLibrary עם המפות במניפסט. --- lib/core/messages/library_messages.dart | 3 + .../bloc/library_update_bloc.dart | 44 +-- .../repository/library_update_repository.dart | 189 ++++++++++++ .../library_update_bloc_test.dart | 43 +++ .../library_update_repository_test.dart | 282 ++++++++++++++++++ 5 files changed, 543 insertions(+), 18 deletions(-) diff --git a/lib/core/messages/library_messages.dart b/lib/core/messages/library_messages.dart index b4a892b93..602e441c1 100644 --- a/lib/core/messages/library_messages.dart +++ b/lib/core/messages/library_messages.dart @@ -94,6 +94,9 @@ abstract class LibraryMessages { static const String localLibraryContentMismatch = 'תוכן הספרייה המקומית שונה מהצפוי'; + static const String libraryContentDriftAfterUpdate = + 'העדכון הוחל, אך תוכן הספרייה המקומית סוטה מהגרסה הרשמית'; + static String fullLibraryDownloadRequired(String reason, String size) => '$reason — נדרשת הורדה מלאה ($size)'; } diff --git a/lib/library_update/bloc/library_update_bloc.dart b/lib/library_update/bloc/library_update_bloc.dart index af8cd293e..e3f9170e5 100644 --- a/lib/library_update/bloc/library_update_bloc.dart +++ b/lib/library_update/bloc/library_update_bloc.dart @@ -279,27 +279,27 @@ class LibraryUpdateBloc extends Bloc { // ביטול לפני apply — לא שגיאה; _onCancel כבר העביר ל-idle. } catch (e, st) { if (_isStale(opId)) return; - _logUpdateError('applyDeltaPlan', e, st); - final partial = e is PartiallyAppliedLibraryDeltaException - ? e.appliedResult - : null; + final drift = e is LibraryDeltaContentDriftException ? e : null; + // סטייה אחרי commit כבר נרשמה ל-errors.txt על ידי הריפוזיטורי. + if (drift == null) _logUpdateError('applyDeltaPlan', e, st); + final partial = switch (e) { + PartiallyAppliedLibraryDeltaException(:final appliedResult) => + appliedResult, + LibraryDeltaContentDriftException(:final appliedResult) => + appliedResult, + _ => null, + }; final applyError = e is PartiallyAppliedLibraryDeltaException ? e.cause : e; - // כל כשל apply (אי-התאמת hash, גרסה/סכמה לא תואמת, patch פגום) הופך - // את מסלול הדלתא ללא בטוח; הורדה מלאה עוקפת אותו. בלי זה, כשל שאינו - // אי-התאמת תוכן — למשל patch בסכמה חדשה מהנתמכת — משאיר את המשתמש - // בלולאת שגיאה ללא מוצא עד עדכון אפליקציה. - if (applyError is PatchApplyException) { - final String mismatchReason; - if (!applyError.isContentMismatch) { - mismatchReason = LibraryMessages.deltaApplyFailed; - } else if (applyError.hashMismatchStage == - PatchHashMismatchStage.toContentHash) { - mismatchReason = LibraryMessages.deltaResultMismatch; - } else { - mismatchReason = LibraryMessages.localLibraryContentMismatch; - } + // כל כשל apply (וגם סטייה שהתגלתה אחרי commit) מנותב להורדה מלאה — אחרת + // כשל שאינו אי-התאמת תוכן משאיר את המשתמש בלולאת שגיאה עד עדכון אפליקציה. + final mismatchReason = drift != null + ? LibraryMessages.libraryContentDriftAfterUpdate + : applyError is PatchApplyException + ? _applyMismatchReason(applyError) + : null; + if (mismatchReason != null) { final fallback = plan.toFullDownloadFallback( reason: mismatchReason, ); @@ -336,6 +336,13 @@ class LibraryUpdateBloc extends Bloc { } } + String _applyMismatchReason(PatchApplyException error) { + if (!error.isContentMismatch) return LibraryMessages.deltaApplyFailed; + return error.hashMismatchStage == PatchHashMismatchStage.toContentHash + ? LibraryMessages.deltaResultMismatch + : LibraryMessages.localLibraryContentMismatch; + } + Future _onConfirmFull( ConfirmFullDownload event, Emitter emit, @@ -568,6 +575,7 @@ class LibraryUpdateBloc extends Bloc { 'upserts' => 'מוסיף ומעדכן רשומות', 'deletes' => 'מסיר רשומות שהוסרו', 'verifyToHash' => 'מאמת את הספרייה המעודכנת', + 'verifyDeferred' => 'בודק את שאר הספרייה (ניתן להמשיך לקרוא)', 'commit' => 'שומר שינויים', _ => 'מחיל עדכון', }; diff --git a/lib/library_update/repository/library_update_repository.dart b/lib/library_update/repository/library_update_repository.dart index ab866caaf..513e4b7cb 100644 --- a/lib/library_update/repository/library_update_repository.dart +++ b/lib/library_update/repository/library_update_repository.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; @@ -95,6 +96,22 @@ class LibraryDeltaApplyResult { ); } +/// העדכון הוחל בהצלחה, אך בבדיקה שאחרי ה-commit נמצאו טבלאות שאף צעד לא נגע +/// בהן ותוכנן סוטה מהצפוי — הספרייה המקומית אינה זהה לגרסה הרשמית. +class LibraryDeltaContentDriftException implements Exception { + final List driftedTables; + final LibraryDeltaApplyResult appliedResult; + + const LibraryDeltaContentDriftException({ + required this.driftedTables, + required this.appliedResult, + }); + + @override + String toString() => + 'LibraryDeltaContentDriftException(${driftedTables.join(', ')})'; +} + /// כשל אחרי שלפחות צעד דלתא אחד כבר הושלם ונכתב ל-DB. /// /// הצעד שנכשל עצמו אטומי ולא נכתב, אך הצעדים שקדמו לו נשארים תקינים ויש @@ -222,6 +239,17 @@ class LibraryUpdateRepository implements LibraryUpdateService { var verifyTotalHint = _readIntQuietly(hintFile); var lastVerifyDone = 0; + // רמז בתים לכל טבלה — נדרש כשהמניפסט מאפשר אימות חלקי, שאז ה-total הוא + // סכום הטבלאות המאומתות בלבד ולא גודל הקובץ. + final tableBytesFile = File( + p.join(cacheDir.path, 'verify_table_bytes.json'), + ); + var verifyTableBytes = _readTableBytesQuietly(tableBytesFile); + + // הטבלאות שאף צעד לא אימת — מועמדות לבדיקת סטייה אחרי סיום השרשרת. + Set? deferredIntersection; + DeltaManifest? lastAppliedManifest; + var result = const LibraryDeltaApplyResult(); final steps = plan.deltaSteps; try { @@ -271,6 +299,7 @@ class LibraryUpdateRepository implements LibraryUpdateService { patchPath: patchPath, step: step, verifyTotalBytesHint: verifyTotalHint, + verifyTableBytesHint: verifyTableBytes, onStage: (stage) => onProgress?.call( LibraryUpdateProgress( phase: LibraryUpdatePhase.applying, @@ -295,6 +324,20 @@ class LibraryUpdateRepository implements LibraryUpdateService { }, ); result = result.addStep(stepResult); + lastAppliedManifest = step.manifest; + final deferred = stepResult.deferredTables.toSet(); + final previousDeferred = deferredIntersection; + deferredIntersection = previousDeferred == null + ? deferred + : previousDeferred.intersection(deferred); + if (stepResult.verifyTableBytes.isNotEmpty) { + final mergedTableBytes = { + ...?verifyTableBytes, + ...stepResult.verifyTableBytes, + }; + verifyTableBytes = mergedTableBytes; + _writeTableBytesQuietly(tableBytesFile, mergedTableBytes); + } // הדיווח האחרון מ-compute הוא הסך המדויק — total לריצות הבאות. if (lastVerifyDone > 0) { verifyTotalHint = lastVerifyDone; @@ -335,12 +378,81 @@ class LibraryUpdateRepository implements LibraryUpdateService { ); await refreshService.refreshAfterDbUpdate(); + // אחרי שחיבור ה-RO נפתח מחדש והריענון הסתיים — מעבר קריאה + // בלבד, בלי תור פעולות, כך שניתן להמשיך לקרוא בזמן הבדיקה. + final drifted = await _verifyDeferredTables( + dbPath: dbPath, + manifest: lastAppliedManifest, + deferred: deferredIntersection, + tableBytesHint: verifyTableBytes, + onProgress: onProgress, + ); + + if (drifted.isNotEmpty) { + try { + ErrorLogFile.append( + title: 'Library Update: content drift in untouched tables', + error: 'tables: ${drifted.join(', ')}', + ); + } catch (_) {} + throw LibraryDeltaContentDriftException( + driftedTables: drifted, + appliedResult: result, + ); + } + onProgress?.call( const LibraryUpdateProgress(phase: LibraryUpdatePhase.done), ); return result; } + /// בודק את הטבלאות שאף צעד לא נגע בהן מול ה-hash של הצעד האחרון, ומחזיר + /// את אלה שסטו. כשל בבדיקה עצמה אינו הופך עדכון תקין לשגיאה. + Future> _verifyDeferredTables({ + required String dbPath, + required DeltaManifest? manifest, + required Set? deferred, + required Map? tableBytesHint, + LibraryUpdateProgressCallback? onProgress, + }) async { + final expected = manifest?.toTableContentHashes; + if (manifest == null || expected == null) return const []; + if (deferred == null || deferred.isEmpty) return const []; + onProgress?.call( + const LibraryUpdateProgress( + phase: LibraryUpdatePhase.applying, + stage: 'verifyDeferred', + ), + ); + try { + return await _verifyTablesInIsolateWithProgress( + dbPath: dbPath, + schemaVersion: manifest.toSchemaVersion, + expected: expected, + tables: deferred.toList(), + tableBytesHint: tableBytesHint, + onVerifyProgress: (done, total) => onProgress?.call( + LibraryUpdateProgress( + phase: LibraryUpdatePhase.applying, + stage: 'verifyDeferred', + applyProgress: total > 0 ? (done / total).clamp(0.0, 1.0) : null, + ), + ), + ); + } catch (error, stackTrace) { + debugPrint('Deferred table verification failed: $error\n$stackTrace'); + try { + ErrorLogFile.append( + title: 'Library Update: deferred verification failed', + error: error, + stackTrace: stackTrace, + ); + } catch (_) {} + return const []; + } + } + /// מבצע הורדה מלאה: מוריד את `seforim.db.zst`, מחלץ בזרימה ליד ה-DB, /// מאמת (quick_check + גרסה), ומחליף אטומית את ה-DB הישן. /// @@ -593,6 +705,7 @@ class LibraryUpdateRepository implements LibraryUpdateService { required String patchPath, required PatchEdge step, int? verifyTotalBytesHint, + Map? verifyTableBytesHint, void Function(String stage)? onStage, void Function(int done, int total)? onVerifyProgress, }) { @@ -621,6 +734,7 @@ class LibraryUpdateRepository implements LibraryUpdateService { patchPath: patchPath, manifest: step.manifest, verifyTotalBytesHint: verifyTotalBytesHint, + verifyTableBytesHint: verifyTableBytesHint, onStage: onStage, onVerifyProgress: onVerifyProgress, ); @@ -687,6 +801,7 @@ class LibraryUpdateRepository implements LibraryUpdateService { required String patchPath, required DeltaManifest manifest, int? verifyTotalBytesHint, + Map? verifyTableBytesHint, void Function(String stage)? onStage, void Function(int done, int total)? onVerifyProgress, }) async { @@ -705,6 +820,7 @@ class LibraryUpdateRepository implements LibraryUpdateService { patchPath: patchPath, manifest: manifest, verifyTotalBytesHint: verifyTotalBytesHint, + verifyTableBytesHint: verifyTableBytesHint, sendPort: port.sendPort, ); } finally { @@ -722,6 +838,7 @@ class LibraryUpdateRepository implements LibraryUpdateService { required DeltaManifest manifest, required SendPort sendPort, int? verifyTotalBytesHint, + Map? verifyTableBytesHint, }) { return Isolate.run( () => const PatchApplier().apply( @@ -729,6 +846,7 @@ class LibraryUpdateRepository implements LibraryUpdateService { patchPath: patchPath, manifest: manifest, verifyTotalBytesHint: verifyTotalBytesHint, + verifyTableBytesHint: verifyTableBytesHint, // verifyFromHash=false: verifyToHash אחרי ה-apply הוא הערובה האמיתית — // אם המקור שונה, ה-toHash ייכשל וה-transaction יתגלגל אחורה. הבדיקה // המקדימה רק כפילה קריאה של כל ה-DB (5.5GB) לחינם. @@ -742,6 +860,55 @@ class LibraryUpdateRepository implements LibraryUpdateService { ); } + // כמו [_applyPatchInIsolate]: ה-callback נשאר ב-caller, ל-isolate נכנסים + // ערכים sendable בלבד. + static Future> _verifyTablesInIsolateWithProgress({ + required String dbPath, + required int schemaVersion, + required Map expected, + required List tables, + Map? tableBytesHint, + void Function(int done, int total)? onVerifyProgress, + }) async { + final port = ReceivePort(); + final sub = port.listen((msg) { + if (msg is (int, int)) onVerifyProgress?.call(msg.$1, msg.$2); + }); + try { + return await _runVerifyTablesIsolate( + dbPath: dbPath, + schemaVersion: schemaVersion, + expected: expected, + tables: tables, + tableBytesHint: tableBytesHint, + sendPort: port.sendPort, + ); + } finally { + await sub.cancel(); + port.close(); + } + } + + static Future> _runVerifyTablesIsolate({ + required String dbPath, + required int schemaVersion, + required Map expected, + required List tables, + required SendPort sendPort, + Map? tableBytesHint, + }) { + return Isolate.run( + () => const PatchApplier().verifyTableHashes( + dbPath: dbPath, + schemaVersion: schemaVersion, + expected: expected, + tables: tables, + tableBytesHint: tableBytesHint, + onProgress: (done, total) => sendPort.send((done, total)), + ), + ); + } + // static מאותה סיבה כמו [_applyPatchInIsolate] — מונע לכידת `this`. static Future _verifyFullDbInIsolate( String newDbPath, @@ -773,4 +940,26 @@ class LibraryUpdateRepository implements LibraryUpdateService { file.writeAsStringSync('$value'); } catch (_) {} } + + static Map? _readTableBytesQuietly(File file) { + try { + if (!file.existsSync()) return null; + final decoded = jsonDecode(file.readAsStringSync()); + if (decoded is! Map) return null; + final result = {}; + decoded.forEach((key, value) { + if (key is String && value is int && value > 0) result[key] = value; + }); + return result.isEmpty ? null : result; + } catch (_) { + return null; + } + } + + static void _writeTableBytesQuietly(File file, Map value) { + try { + file.parent.createSync(recursive: true); + file.writeAsStringSync(jsonEncode(value)); + } catch (_) {} + } } diff --git a/test/library_update/library_update_bloc_test.dart b/test/library_update/library_update_bloc_test.dart index 242207506..24469cf33 100644 --- a/test/library_update/library_update_bloc_test.dart +++ b/test/library_update/library_update_bloc_test.dart @@ -1564,6 +1564,49 @@ void main() { ], ); + blocTest( + 'סטייה בטבלאות שלא נגעו בהן אחרי commit → fallback עם שינויי הצעדים', + build: () => _bloc( + _FakeService( + deltaWithFallbackPlan, + applyError: const LibraryDeltaContentDriftException( + driftedTables: ['author', 'topic'], + appliedResult: LibraryDeltaApplyResult( + changedBookIds: {3, 9}, + requiresFullIndexRefresh: true, + appliedSteps: 1, + ), + ), + ), + ), + act: (b) => b.add(const StartLibraryUpdate()), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + LibraryUpdateStatus.checking, + ), + isA() + .having( + (s) => s.status, + 'status', + LibraryUpdateStatus.needsFullConfirmation, + ) + .having( + (s) => s.message, + 'message', + contains(LibraryMessages.libraryContentDriftAfterUpdate), + ) + .having((s) => s.hasUpdate, 'hasUpdate', true) + .having((s) => s.changedBookIds, 'changedBookIds', const {3, 9}) + .having( + (s) => s.requiresFullIndexRefresh, + 'requiresFullIndexRefresh', + true, + ), + ], + ); + blocTest( 'סטיית תוכן בלי DB מלא בתוכנית → error', build: () => _bloc( diff --git a/test/library_update/library_update_repository_test.dart b/test/library_update/library_update_repository_test.dart index dd3094b11..1fcbc8ac2 100644 --- a/test/library_update/library_update_repository_test.dart +++ b/test/library_update/library_update_repository_test.dart @@ -664,6 +664,264 @@ void main() { timeout: const Timeout(Duration(seconds: 30)), ); + test( + 'מניפסט עם hash לכל טבלה: אימות חלקי, שלב verifyDeferred, בלי סטייה', + () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeSchema4SourceDb(dbPath, version: 1, sourceName: 'old'); + final expectedPath = p.join(tmp.path, 'expected.db'); + _writeSchema4SourceDb(expectedPath, version: 2, sourceName: 'new'); + final patchPath = p.join(tmp.path, 'patch-1-2.db'); + _writeSourcePatch( + patchPath, + fromVersion: 1, + toVersion: 2, + sourceName: 'new', + ); + final repository = LibraryUpdateRepository( + discovery: _unusedDiscovery(), + downloader: _PatchMapDownloader({'patch-1-2.db': patchPath}), + refreshService: _NoopRefreshService(), + dbPathProvider: () => dbPath, + dataRootProvider: () async => tmp.path, + nowTimestamp: () => '2026-09-07T00:00:00Z', + ); + final stages = []; + + final result = await repository.applyDeltaPlan( + _schema4DeltaPlan([ + _schema4Edge( + fromVersion: 1, + toVersion: 2, + patchName: 'patch-1-2.db', + toHash: _logicalHash(expectedPath), + fromTableHashes: _tableHashes(dbPath), + toTableHashes: _tableHashes(expectedPath), + ), + ]), + onProgress: (progress) { + final stage = progress.stage; + if (stage != null) stages.add(stage); + }, + ); + + expect(result.appliedSteps, 1); + expect(stages, contains('verifyDeferred')); + expect(_readSourceName(dbPath), 'new'); + // רמז הבתים לכל טבלה נשמר לריצה הבאה. + final hintFile = File( + p.join(tmp.path, 'library_update_cache', 'verify_table_bytes.json'), + ); + expect(hintFile.existsSync(), isTrue); + expect( + (jsonDecode(hintFile.readAsStringSync()) as Map).keys, + contains('source'), + ); + }, + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + 'סטייה בטבלה שאף צעד לא נגע בה מדווחת אחרי ה-commit ונרשמת ל-errors.txt', + () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + final pristinePath = p.join(tmp.path, 'pristine.db'); + final expectedPath = p.join(tmp.path, 'expected.db'); + // ה-manifest נבנה מ-DB תקין; המקומי זהה לו פרט ל-author שסטה. + _writeSchema4SourceDb( + pristinePath, + version: 1, + sourceName: 'old', + authorName: 'תקין', + ); + _writeSchema4SourceDb( + expectedPath, + version: 2, + sourceName: 'new', + authorName: 'תקין', + ); + _writeSchema4SourceDb( + dbPath, + version: 1, + sourceName: 'old', + authorName: 'סוטה', + ); + final patchPath = p.join(tmp.path, 'patch-1-2.db'); + _writeSourcePatch( + patchPath, + fromVersion: 1, + toVersion: 2, + sourceName: 'new', + ); + AppPaths.debugOverrideDataRootPath(tmp.path); + final refresh = _NoopRefreshService(); + final repository = LibraryUpdateRepository( + discovery: _unusedDiscovery(), + downloader: _PatchMapDownloader({'patch-1-2.db': patchPath}), + refreshService: refresh, + dbPathProvider: () => dbPath, + dataRootProvider: () async => tmp.path, + nowTimestamp: () => '2026-09-07T00:00:00Z', + ); + + try { + await expectLater( + repository.applyDeltaPlan( + _schema4DeltaPlan([ + _schema4Edge( + fromVersion: 1, + toVersion: 2, + patchName: 'patch-1-2.db', + toHash: _logicalHash(expectedPath), + fromTableHashes: _tableHashes(pristinePath), + toTableHashes: _tableHashes(expectedPath), + ), + ]), + ), + throwsA( + isA() + .having( + (e) => e.driftedTables, + 'driftedTables', + contains('author'), + ) + .having((e) => e.appliedResult.appliedSteps, 'appliedSteps', 1), + ), + ); + + // העדכון עצמו הוחל ורוענן — הסטייה אינה rollback. + expect(_readSourceName(dbPath), 'new'); + expect(refresh.called, isTrue); + expect( + ErrorLogFile.resolveFile().readAsStringSync(), + contains('Library Update: content drift in untouched tables'), + ); + } finally { + AppPaths.debugOverrideDataRootPath(null); + } + }, + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + 'שרשרת שבה צעד אחד בלי hash לכל טבלה: אין שלב verifyDeferred', + () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeSchema4SourceDb(dbPath, version: 1, sourceName: 'old'); + final expected2 = p.join(tmp.path, 'expected2.db'); + _writeSchema4SourceDb(expected2, version: 2, sourceName: 'new'); + final expected3 = p.join(tmp.path, 'expected3.db'); + _writeSchema4SourceDb(expected3, version: 3, sourceName: 'newer'); + final firstPatch = p.join(tmp.path, 'patch-1-2.db'); + final secondPatch = p.join(tmp.path, 'patch-2-3.db'); + _writeSourcePatch( + firstPatch, + fromVersion: 1, + toVersion: 2, + sourceName: 'new', + ); + _writeSourcePatch( + secondPatch, + fromVersion: 2, + toVersion: 3, + sourceName: 'newer', + ); + final repository = LibraryUpdateRepository( + discovery: _unusedDiscovery(), + downloader: _PatchMapDownloader({ + 'patch-1-2.db': firstPatch, + 'patch-2-3.db': secondPatch, + }), + refreshService: _NoopRefreshService(), + dbPathProvider: () => dbPath, + dataRootProvider: () async => tmp.path, + nowTimestamp: () => '2026-09-07T00:00:00Z', + ); + final stages = []; + + final result = await repository.applyDeltaPlan( + _schema4DeltaPlan([ + _schema4Edge( + fromVersion: 1, + toVersion: 2, + patchName: 'patch-1-2.db', + toHash: _logicalHash(expected2), + fromTableHashes: _tableHashes(dbPath), + toTableHashes: _tableHashes(expected2), + ), + _schema4Edge( + fromVersion: 2, + toVersion: 3, + patchName: 'patch-2-3.db', + toHash: _logicalHash(expected3), + ), + ]), + onProgress: (progress) { + final stage = progress.stage; + if (stage != null) stages.add(stage); + }, + ); + + // הצעד הישן אימת את כל ה-DB, ולכן אין טבלה שנותרה לא-מאומתת. + expect(result.appliedSteps, 2); + expect(_readSourceName(dbPath), 'newer'); + expect(stages, isNot(contains('verifyDeferred'))); + }, + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + 'מניפסט בלי hash לכל טבלה: אימות DB מלא, בלי שלב verifyDeferred', + () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + _writeSchema4SourceDb(dbPath, version: 1, sourceName: 'old'); + final expectedPath = p.join(tmp.path, 'expected.db'); + _writeSchema4SourceDb(expectedPath, version: 2, sourceName: 'new'); + final patchPath = p.join(tmp.path, 'patch-1-2.db'); + _writeSourcePatch( + patchPath, + fromVersion: 1, + toVersion: 2, + sourceName: 'new', + ); + final repository = LibraryUpdateRepository( + discovery: _unusedDiscovery(), + downloader: _PatchMapDownloader({'patch-1-2.db': patchPath}), + refreshService: _NoopRefreshService(), + dbPathProvider: () => dbPath, + dataRootProvider: () async => tmp.path, + nowTimestamp: () => '2026-09-07T00:00:00Z', + ); + final stages = []; + + final result = await repository.applyDeltaPlan( + _schema4DeltaPlan([ + _schema4Edge( + fromVersion: 1, + toVersion: 2, + patchName: 'patch-1-2.db', + toHash: _logicalHash(expectedPath), + ), + ]), + onProgress: (progress) { + final stage = progress.stage; + if (stage != null) stages.add(stage); + }, + ); + + expect(result.appliedSteps, 1); + expect(stages, contains('verifyToHash')); + expect(stages, isNot(contains('verifyDeferred'))); + expect( + File( + p.join(tmp.path, 'library_update_cache', 'verify_table_bytes.json'), + ).existsSync(), + isFalse, + ); + }, + timeout: const Timeout(Duration(seconds: 60)), + ); + test( 'קורא RO ממשיך לקרוא בזמן כתיבת WAL (הנחת היסוד של עדכון ללא חסימה)', () async { @@ -907,6 +1165,21 @@ class _PatchMapDownloader extends PatchDownloader { }) async => patchPaths[patchFile.file]!; } +/// ה-hash לכל טבלה בסדר של סכמה 4 — הבסיס למפות שבמניפסט. +Map _tableHashes(String dbPath, {int schemaVersion = 4}) { + final db = sqlite3.sqlite3.open(dbPath, mode: sqlite3.OpenMode.readOnly); + try { + return const LogicalContentHasher() + .computeReport( + db, + tableOrder: hashTableOrderForSchemaVersion(schemaVersion), + ) + .tableHashes; + } finally { + db.close(); + } +} + String _logicalHash(String dbPath) { final db = sqlite3.sqlite3.open(dbPath, mode: sqlite3.OpenMode.readOnly); try { @@ -920,6 +1193,7 @@ void _writeSchema4SourceDb( String dbPath, { required int version, required String sourceName, + String? authorName, }) { final db = sqlite3.sqlite3.open(dbPath); try { @@ -931,6 +1205,10 @@ void _writeSchema4SourceDb( ); db.execute('CREATE TABLE source (id INTEGER PRIMARY KEY, name TEXT)'); db.execute('INSERT INTO source VALUES (1, ?)', [sourceName]); + if (authorName != null) { + db.execute('CREATE TABLE author (id INTEGER PRIMARY KEY, name TEXT)'); + db.execute('INSERT INTO author VALUES (1, ?)', [authorName]); + } db.execute('PRAGMA journal_mode=DELETE'); } finally { db.close(); @@ -976,6 +1254,8 @@ PatchEdge _schema4Edge({ required int toVersion, required String patchName, required String toHash, + Map? fromTableHashes, + Map? toTableHashes, }) { final manifest = DeltaManifest.fromJson({ 'fromVersion': fromVersion, @@ -985,6 +1265,8 @@ PatchEdge _schema4Edge({ 'patchFormatVersion': 4, 'fromContentHash': 'unused', 'toContentHash': toHash, + 'fromTableContentHashes': ?fromTableHashes, + 'toTableContentHashes': ?toTableHashes, 'patchFiles': [ { 'file': patchName, From a95a31c3aec5b5ffc4bb38752c9cc6eedd592fd5 Mon Sep 17 00:00:00 2001 From: ypl <7353755@gmail.com> Date: Mon, 7 Sep 2026 15:12:46 +0300 Subject: [PATCH 2/3] fix: opt in to partial table verification --- .../repository/library_update_repository.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/library_update/repository/library_update_repository.dart b/lib/library_update/repository/library_update_repository.dart index 513e4b7cb..8e4818046 100644 --- a/lib/library_update/repository/library_update_repository.dart +++ b/lib/library_update/repository/library_update_repository.dart @@ -847,12 +847,16 @@ class LibraryUpdateRepository implements LibraryUpdateService { manifest: manifest, verifyTotalBytesHint: verifyTotalBytesHint, verifyTableBytesHint: verifyTableBytesHint, + // האימות החלקי הוא opt-in ב-updater: הריפוזיטורי משלים אותו במעבר + // read-only על deferredTables אחרי ה-commit (ראו _verifyDeferredTables). + enablePartialTableVerification: true, // verifyFromHash=false: verifyToHash אחרי ה-apply הוא הערובה האמיתית — // אם המקור שונה, ה-toHash ייכשל וה-transaction יתגלגל אחורה. הבדיקה // המקדימה רק כפילה קריאה של כל ה-DB (5.5GB) לחינם. verifyFromHash: false, - // checkForeignKeys=false: verifyToHash מאמת את כל 28 הטבלאות (וכל ה-FK - // שביניהן) מול ה-DB התקין, אז התאמת hash כבר שוללת הפרות FK — חוסך ~60ש. + // checkForeignKeys=false: התאמת ה-hash של הטבלאות שנגעו בהן ל-DB + // הקנוני שוללת הפרות שה-patch יצר; הטבלאות האחרות נבדקות במעבר + // deferred שאחרי ה-commit. כך נמנעת סריקת FK מלאה נוספת. checkForeignKeys: false, onStage: (stage) => sendPort.send(stage), onVerifyProgress: (done, total) => sendPort.send((done, total)), From ae468b26d79875605d952a7bb01ecfd41adb3390 Mon Sep 17 00:00:00 2001 From: ypl <7353755@gmail.com> Date: Tue, 8 Sep 2026 00:35:22 +0300 Subject: [PATCH 3/3] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=A1?= =?UTF-8?q?=D7=A4=D7=A8=D7=99=D7=99=D7=94:=20=D7=90=D7=99=D7=9E=D7=95?= =?UTF-8?q?=D7=AA=20=D7=A1=D7=98=D7=99=D7=99=D7=94=20=D7=92=D7=9D=20=D7=90?= =?UTF-8?q?=D7=97=D7=A8=D7=99=20=D7=94=D7=97=D7=9C=D7=94=20=D7=97=D7=9C?= =?UTF-8?q?=D7=A7=D7=99=D7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bloc/library_update_bloc.dart | 7 +- .../repository/library_update_repository.dart | 42 ++++++--- .../library_update_bloc_test.dart | 1 - .../library_update_repository_test.dart | 92 +++++++++++++++++++ 4 files changed, 124 insertions(+), 18 deletions(-) diff --git a/lib/library_update/bloc/library_update_bloc.dart b/lib/library_update/bloc/library_update_bloc.dart index e3f9170e5..bcd61dde6 100644 --- a/lib/library_update/bloc/library_update_bloc.dart +++ b/lib/library_update/bloc/library_update_bloc.dart @@ -292,6 +292,8 @@ class LibraryUpdateBloc extends Bloc { final applyError = e is PartiallyAppliedLibraryDeltaException ? e.cause : e; + final requiresFullIndexRefresh = + drift != null || (partial?.requiresFullIndexRefresh ?? false); // כל כשל apply (וגם סטייה שהתגלתה אחרי commit) מנותב להורדה מלאה — אחרת // כשל שאינו אי-התאמת תוכן משאיר את המשתמש בלולאת שגיאה עד עדכון אפליקציה. final mismatchReason = drift != null @@ -314,8 +316,7 @@ class LibraryUpdateBloc extends Bloc { plan: fallback, hasUpdate: partial?.hasDatabaseChanges ?? false, changedBookIds: partial?.changedBookIds ?? const {}, - requiresFullIndexRefresh: - partial?.requiresFullIndexRefresh ?? false, + requiresFullIndexRefresh: requiresFullIndexRefresh, ), ); return; @@ -327,7 +328,7 @@ class LibraryUpdateBloc extends Bloc { message: 'שגיאה בהחלת העדכון', hasUpdate: partial?.hasDatabaseChanges ?? false, changedBookIds: partial?.changedBookIds ?? const {}, - requiresFullIndexRefresh: partial?.requiresFullIndexRefresh ?? false, + requiresFullIndexRefresh: requiresFullIndexRefresh, errorMessage: applyError.toString(), ), ); diff --git a/lib/library_update/repository/library_update_repository.dart b/lib/library_update/repository/library_update_repository.dart index 8e4818046..916a7d3f6 100644 --- a/lib/library_update/repository/library_update_repository.dart +++ b/lib/library_update/repository/library_update_repository.dart @@ -338,8 +338,8 @@ class LibraryUpdateRepository implements LibraryUpdateService { verifyTableBytes = mergedTableBytes; _writeTableBytesQuietly(tableBytesFile, mergedTableBytes); } - // הדיווח האחרון מ-compute הוא הסך המדויק — total לריצות הבאות. - if (lastVerifyDone > 0) { + // באימות מלא הדיווח האחרון הוא ה-total המדויק לריצה הבאה. + if (lastVerifyDone > 0 && deferred.isEmpty) { verifyTotalHint = lastVerifyDone; _writeIntQuietly(hintFile, lastVerifyDone); } @@ -363,6 +363,14 @@ class LibraryUpdateRepository implements LibraryUpdateService { // הכשל המקורית; ה-BLoC עדיין יוכל להציע fallback ולרענן אחרי ההחלטה. refreshError = error; } + final drifted = await _verifyDeferredTables( + dbPath: dbPath, + manifest: lastAppliedManifest, + deferred: deferredIntersection, + tableBytesHint: verifyTableBytes, + onProgress: onProgress, + ); + _throwIfContentDrifted(drifted, result); Error.throwWithStackTrace( PartiallyAppliedLibraryDeltaException( cause: error, @@ -388,18 +396,7 @@ class LibraryUpdateRepository implements LibraryUpdateService { onProgress: onProgress, ); - if (drifted.isNotEmpty) { - try { - ErrorLogFile.append( - title: 'Library Update: content drift in untouched tables', - error: 'tables: ${drifted.join(', ')}', - ); - } catch (_) {} - throw LibraryDeltaContentDriftException( - driftedTables: drifted, - appliedResult: result, - ); - } + _throwIfContentDrifted(drifted, result); onProgress?.call( const LibraryUpdateProgress(phase: LibraryUpdatePhase.done), @@ -407,6 +404,23 @@ class LibraryUpdateRepository implements LibraryUpdateService { return result; } + void _throwIfContentDrifted( + List drifted, + LibraryDeltaApplyResult result, + ) { + if (drifted.isEmpty) return; + try { + ErrorLogFile.append( + title: 'Library Update: content drift in untouched tables', + error: 'tables: ${drifted.join(', ')}', + ); + } catch (_) {} + throw LibraryDeltaContentDriftException( + driftedTables: drifted, + appliedResult: result, + ); + } + /// בודק את הטבלאות שאף צעד לא נגע בהן מול ה-hash של הצעד האחרון, ומחזיר /// את אלה שסטו. כשל בבדיקה עצמה אינו הופך עדכון תקין לשגיאה. Future> _verifyDeferredTables({ diff --git a/test/library_update/library_update_bloc_test.dart b/test/library_update/library_update_bloc_test.dart index 24469cf33..7e44d56a8 100644 --- a/test/library_update/library_update_bloc_test.dart +++ b/test/library_update/library_update_bloc_test.dart @@ -1573,7 +1573,6 @@ void main() { driftedTables: ['author', 'topic'], appliedResult: LibraryDeltaApplyResult( changedBookIds: {3, 9}, - requiresFullIndexRefresh: true, appliedSteps: 1, ), ), diff --git a/test/library_update/library_update_repository_test.dart b/test/library_update/library_update_repository_test.dart index 1fcbc8ac2..a40ed4f69 100644 --- a/test/library_update/library_update_repository_test.dart +++ b/test/library_update/library_update_repository_test.dart @@ -717,6 +717,12 @@ void main() { (jsonDecode(hintFile.readAsStringSync()) as Map).keys, contains('source'), ); + expect( + File( + p.join(tmp.path, 'library_update_cache', 'verify_total_bytes.txt'), + ).existsSync(), + isFalse, + ); }, timeout: const Timeout(Duration(seconds: 60)), ); @@ -870,6 +876,92 @@ void main() { timeout: const Timeout(Duration(seconds: 60)), ); + test( + 'כשל בצעד מאוחר עדיין מדווח סטייה בטבלה שנדחתה בצעד שהושלם', + () async { + final dbPath = p.join(tmp.path, DatabaseConstants.databaseFileName); + final pristinePath = p.join(tmp.path, 'pristine.db'); + final expectedPath = p.join(tmp.path, 'expected.db'); + _writeSchema4SourceDb( + pristinePath, + version: 1, + sourceName: 'old', + authorName: 'תקין', + ); + _writeSchema4SourceDb( + expectedPath, + version: 2, + sourceName: 'new', + authorName: 'תקין', + ); + _writeSchema4SourceDb( + dbPath, + version: 1, + sourceName: 'old', + authorName: 'סוטה', + ); + final firstPatch = p.join(tmp.path, 'patch-1-2.db'); + final invalidPatch = p.join(tmp.path, 'patch-2-3.db'); + _writeSourcePatch( + firstPatch, + fromVersion: 1, + toVersion: 2, + sourceName: 'new', + ); + _writeSourcePatch( + invalidPatch, + fromVersion: 2, + toVersion: 3, + sourceName: 'newer', + patchFormatVersion: 99, + ); + final repository = LibraryUpdateRepository( + discovery: _unusedDiscovery(), + downloader: _PatchMapDownloader({ + 'patch-1-2.db': firstPatch, + 'patch-2-3.db': invalidPatch, + }), + refreshService: _NoopRefreshService(), + dbPathProvider: () => dbPath, + dataRootProvider: () async => tmp.path, + nowTimestamp: () => '2026-09-08T00:00:00Z', + ); + + await expectLater( + repository.applyDeltaPlan( + _schema4DeltaPlan([ + _schema4Edge( + fromVersion: 1, + toVersion: 2, + patchName: 'patch-1-2.db', + toHash: _logicalHash(expectedPath), + fromTableHashes: _tableHashes(pristinePath), + toTableHashes: _tableHashes(expectedPath), + ), + _schema4Edge( + fromVersion: 2, + toVersion: 3, + patchName: 'patch-2-3.db', + toHash: 'unused', + ), + ]), + ), + throwsA( + isA() + .having( + (e) => e.driftedTables, + 'driftedTables', + contains('author'), + ) + .having((e) => e.appliedResult.appliedSteps, 'appliedSteps', 1), + ), + ); + expect(const LocalDbVersionReader().read(dbPath).dbVersion, 2); + expect(_readSourceName(dbPath), 'new'); + }, + timeout: const Timeout(Duration(seconds: 60)), + ); + test( 'מניפסט בלי hash לכל טבלה: אימות DB מלא, בלי שלב verifyDeferred', () async {