diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 9caeb3b..7d14b5e 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -12,6 +12,7 @@ jobs: runs-on: ubuntu-latest env: KOTLIN_FIXTURE: seforim-library/generator/common/src/jvmTest/resources/patch_tables_contract.json + KOTLIN_HASH_FIXTURE: seforim-library/generator/common/src/jvmTest/resources/logical_hash_contract.json steps: - uses: actions/checkout@v4 @@ -53,10 +54,16 @@ jobs: - name: Verify Kotlin fixture exists run: test -f "$KOTLIN_FIXTURE" + - name: Verify Kotlin hash fixture exists + run: test -f "$KOTLIN_HASH_FIXTURE" + # השוואת בתים ישירה בין שני ה-fixtures — בלתי תלויה ב-Dart - name: cmp fixtures (byte-identical) run: cmp test/patch_tables_contract.json "$KOTLIN_FIXTURE" + - name: cmp hash fixtures (byte-identical) + run: cmp test/logical_hash_contract.json "$KOTLIN_HASH_FIXTURE" + - uses: dart-lang/setup-dart@v1 - run: dart pub get diff --git a/CHANGELOG.md b/CHANGELOG.md index da58d21..44eaacc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 0.4.0 + +hash תוכן לוגי לכל טבלה — אימות אחרי apply רק על הטבלאות שהשתנו, במקום +הזרמת כל ה-DB (‏~80% מ-6GB) בכל צעד בשרשרת. + +- `LogicalContentHasher.computeReport` — מעבר יחיד שמחזיר גם את ה-hash הכולל + וגם `tableHashes`/`tableBytes` לכל טבלה. `tableHash(t)` הוא sha256 של בדיוק + הבתים שהטבלה תורמת לזרם הכולל (כולל הקידומת `" table: "`), ולכן + `wholeHash == sha256` של שרשור הזרמים. `compute` נשאר עם אותה חתימה ומאציל. + `only` מבקש תת-קבוצה של טבלאות, ואז `wholeHash` הוא null. +- `DeltaManifest`: שדות אופציונליים `fromTableContentHashes` ו- + `toTableContentHashes` (מפה ` -> `). שניהם יחד או אף אחד — + מניפסט שנושא רק אחד מהם נקרא כאילו אין מפות; ערך קיים שאינו מפה של מחרוזות + לא ריקות זורק `FormatException`. +- `PatchApplier.apply`: כש-`enablePartialTableVerification: true` והמניפסט + נושא שתי מפות שמפתחותיהן הם בדיוק סדרי ה-hash של + `fromSchemaVersion`/`toSchemaVersion`, שלב `verifyToHash` מאמת רק + `{טבלאות ש-from≠to} ∪ {טבלאות שה-patch נגע בהן} ∪ {schema_meta}`. אחרת — + אימות ה-DB המלא בדיוק כמו קודם. +- `PatchApplyResult`: ‏`verifiedTables`, `deferredTables` (הטבלאות שדולגו, + לאימות אחרי ה-commit) ו-`verifyTableBytes` (רמז התקדמות לריצה הבאה). + `apply` מקבל `verifyTableBytesHint` שקובע את ה-total של מד ההתקדמות. +- `PatchApplyException.mismatchedTables` — שמות הטבלאות שלא תאמו, כשהאימות + היה לפי טבלאות (null באימות מלא). כל אי-התאמה עדיין גוררת ROLLBACK. +- `PatchApplier.verifyTableHashes` — אימות קריאה-בלבד של טבלאות נבחרות מול + מפה נתונה, ללא transaction. נועד לצרכן שרוצה לאמת את `deferredTables` + אחרי שה-commit הסתיים והספרייה כבר קריאה. +- `test/logical_hash_contract.json` — oracle משותף עם צד ה-Kotlin (עותק זהה + אות-באות), נאכף ב-`contract.yml` ב-`cmp`. + ## 0.3.0 תמיכה בסכמת patch 4 — טבלאות `line_ref` (אינדקס הפניות קנוני) ו-`line_dh` diff --git a/lib/seforim_library_updater.dart b/lib/seforim_library_updater.dart index 4106add..1afa669 100644 --- a/lib/seforim_library_updater.dart +++ b/lib/seforim_library_updater.dart @@ -23,13 +23,15 @@ export 'src/services/library_update_discovery.dart' export 'src/services/library_update_planner.dart' show LibraryUpdatePlanner; export 'src/services/local_db_version_reader.dart' show LocalDbVersionReader, LocalDbVersion; -export 'src/services/logical_content_hasher.dart' show LogicalContentHasher; +export 'src/services/logical_content_hasher.dart' + show LogicalContentHasher, LogicalContentHashReport; export 'src/services/patch_applier.dart' show PatchApplier, PatchApplyResult, PatchApplyException, PatchHashMismatchStage, + hashTableOrderForSchemaVersion, kBooksTouchedTables; export 'src/services/patch_downloader.dart' show PatchDownloader, PatchDownloadException, PatchDownloadCancelled; diff --git a/lib/src/models/delta_manifest.dart b/lib/src/models/delta_manifest.dart index 445fdfb..e88bf5a 100644 --- a/lib/src/models/delta_manifest.dart +++ b/lib/src/models/delta_manifest.dart @@ -79,6 +79,14 @@ class DeltaManifest extends Equatable { /// קבצי ה-patch להורדה והחלה (כרגע תמיד קובץ אחד). final List patchFiles; + /// hash לכל טבלה של ה-DB *לפני* ה-patch, לפי סדר ה-hash של + /// [fromSchemaVersion]. null במניפסט ישן, או כשרק אחת משתי המפות קיימת. + final Map? fromTableContentHashes; + + /// hash לכל טבלה של ה-DB *אחרי* ה-patch, לפי סדר ה-hash של + /// [toSchemaVersion]. מאפשר ללקוח לאמת רק את הטבלאות שהשתנו. + final Map? toTableContentHashes; + /// שדות אופציונליים עתידיים — נשמרים אם קיימים, אך אינם חובה. final List booksTouched; final String? catalogBlobName; @@ -91,6 +99,8 @@ class DeltaManifest extends Equatable { this.patchFormatVersion, required this.fromContentHash, required this.toContentHash, + this.fromTableContentHashes, + this.toTableContentHashes, required this.patchFiles, this.booksTouched = const [], this.catalogBlobName, @@ -112,6 +122,14 @@ class DeltaManifest extends Equatable { 'שדה חובה חסר במניפסט של schema 4+: patchFormatVersion', ); } + // שתי המפות מגיעות כזוג; אחת בלבד היא מניפסט לא עקבי, ואז נופלים לאימות + // ה-DB המלא כאילו אינן קיימות. + var fromTables = _optionalStringMap(json, 'fromTableContentHashes'); + var toTables = _optionalStringMap(json, 'toTableContentHashes'); + if (fromTables == null || toTables == null) { + fromTables = null; + toTables = null; + } return DeltaManifest( fromVersion: _requireInt(json, 'fromVersion'), toVersion: _requireInt(json, 'toVersion'), @@ -120,6 +138,8 @@ class DeltaManifest extends Equatable { patchFormatVersion: patchFormatVersion, fromContentHash: _requireString(json, 'fromContentHash'), toContentHash: _requireString(json, 'toContentHash'), + fromTableContentHashes: fromTables, + toTableContentHashes: toTables, patchFiles: patchFilesRaw .map((e) => PatchFileEntry.fromJson(e as Map)) .toList(growable: false), @@ -143,6 +163,8 @@ class DeltaManifest extends Equatable { patchFormatVersion, fromContentHash, toContentHash, + fromTableContentHashes, + toTableContentHashes, patchFiles, booksTouched, catalogBlobName, @@ -165,6 +187,24 @@ int _requireInt(Map json, String key) { return value.toInt(); } +/// מפענח מפת `
-> ` אופציונלית. null כשהשדה חסר; [FormatException] +/// כשהוא קיים אך אינו מפה של מחרוזות לא ריקות. +Map? _optionalStringMap(Map json, String key) { + if (!json.containsKey(key) || json[key] == null) return null; + final value = json[key]; + if (value is! Map) { + throw FormatException('שדה אופציונלי לא תקין במניפסט: $key'); + } + final result = {}; + value.forEach((k, v) { + if (k is! String || k.isEmpty || v is! String || v.isEmpty) { + throw FormatException('ערך לא תקין במפת ה-hash במניפסט: $key'); + } + result[k] = v; + }); + return result; +} + int? _optionalInt(Map json, String key) { if (!json.containsKey(key) || json[key] == null) return null; final value = json[key]; diff --git a/lib/src/services/logical_content_hasher.dart b/lib/src/services/logical_content_hasher.dart index 97ffee6..358691f 100644 --- a/lib/src/services/logical_content_hasher.dart +++ b/lib/src/services/logical_content_hasher.dart @@ -42,49 +42,98 @@ class LogicalContentHasher { /// [tableOrder] — סדר הטבלאות לשקלול ב-hash. ברירת מחדל: [kHashTableOrder] /// (34 טבלאות, סכמה-2). ה-caller בוחר את הסדר לפי גרסת הסכמה של ה-DB. String compute(sqlite3.Database db, + {List tableOrder = kHashTableOrder, + void Function(int bytesHashed)? onProgress}) => + _compute(db, + tableOrder: tableOrder, + onProgress: onProgress, + includeTableHashes: false).wholeHash!; + + /// מחשב במעבר יחיד את ה-hash הכולל ואת ה-hash של כל טבלה בנפרד + /// (`tableHash(t) = sha256` של בדיוק הבתים שהטבלה תורמת לזרם הכולל). + /// + /// [only] — כשניתן, מחושבות רק הטבלאות שבו (בסדר [tableOrder]), + /// ו-[LogicalContentHashReport.wholeHash] יהיה null. + LogicalContentHashReport computeReport(sqlite3.Database db, {List tableOrder = kHashTableOrder, + Set? only, + void Function(int bytesHashed)? onProgress}) => + _compute(db, + tableOrder: tableOrder, + only: only, + onProgress: onProgress, + includeTableHashes: true); + + /// המימוש המשותף שומר על [compute] זול: המסלול הוותיק מחשב SHA יחיד בלבד. + /// hash לכל טבלה מחושב רק דרך [computeReport], שבו הוא נדרש בפועל. + LogicalContentHashReport _compute(sqlite3.Database db, + {required List tableOrder, + required bool includeTableHashes, + Set? only, void Function(int bytesHashed)? onProgress}) { - final digestSink = _DigestSink(); - final shaSink = sha256.startChunkedConversion(digestSink); - final out = _BufferedByteSink(shaSink, onProgress: onProgress); + assert(includeTableHashes || only == null); + final wholeDigest = only == null ? _DigestSink() : null; + final wholeSink = + wholeDigest == null ? null : sha256.startChunkedConversion(wholeDigest); + final out = _BufferedByteSink(wholeSink, onProgress: onProgress); + final tableHashes = {}; + final tableBytes = {}; for (final table in tableOrder) { + if (only != null && !only.contains(table)) continue; + final tableDigest = includeTableHashes ? _DigestSink() : null; + final tableSink = tableDigest == null + ? null + : sha256.startChunkedConversion(tableDigest); + if (tableSink != null) out.beginTable(tableSink); + out.addBytes(utf8.encode(' table:$table ')); final cols = _readColumnsCanonical(db, table); - if (cols == null) continue; - out.addBytes(utf8.encode('cols:${cols.join(',')}')); - out.addByte(_nullTag); - - // ל-text קוראים את ה-bytes הגולמיים (CAST AS BLOB) כדי לא לאבד BOM - // מוביל — ה-decoder של Dart מסיר U+FEFF, ולכן String רגיל היה משנה את - // ה-hash. typeof קובע את בית-הסוג; ה-CASE מחזיר blob רק ל-text. - final selectCols = cols - .map((c) => 'typeof("$c"),CASE WHEN typeof("$c")=\'text\' ' - 'THEN CAST("$c" AS BLOB) ELSE "$c" END') - .join(','); - final orderBy = - cols.contains('id') ? 'id' : cols.map((c) => '"$c"').join(','); - final stmt = - db.prepare('SELECT $selectCols FROM "$table" ORDER BY $orderBy'); - try { - final cursor = stmt.selectCursor(const []); - while (cursor.moveNext()) { - final values = cursor.current.values; - for (var i = 0; i < values.length; i += 2) { - _encodeCell(out, values[i] as String, values[i + 1]); + if (cols != null) { + out.addBytes(utf8.encode('cols:${cols.join(',')}')); + out.addByte(_nullTag); + + // ל-text קוראים את ה-bytes הגולמיים (CAST AS BLOB) כדי לא לאבד BOM + // מוביל — ה-decoder של Dart מסיר U+FEFF, ולכן String רגיל היה משנה את + // ה-hash. typeof קובע את בית-הסוג; ה-CASE מחזיר blob רק ל-text. + final selectCols = cols + .map((c) => 'typeof("$c"),CASE WHEN typeof("$c")=\'text\' ' + 'THEN CAST("$c" AS BLOB) ELSE "$c" END') + .join(','); + final orderBy = + cols.contains('id') ? 'id' : cols.map((c) => '"$c"').join(','); + final stmt = + db.prepare('SELECT $selectCols FROM "$table" ORDER BY $orderBy'); + try { + final cursor = stmt.selectCursor(const []); + while (cursor.moveNext()) { + final values = cursor.current.values; + for (var i = 0; i < values.length; i += 2) { + _encodeCell(out, values[i] as String, values[i + 1]); + } + out.addByte(_rowSeparator); } - out.addByte(_rowSeparator); + } finally { + stmt.close(); } - } finally { - stmt.close(); + } + + if (tableSink != null) { + tableBytes[table] = out.endTable(); + tableSink.close(); + tableHashes[table] = tableDigest!.digest.toString(); } } out.flush(); // דיווח סופי מדויק — מאפשר ל-caller לשמור את סך-הבתים האמיתי לריצה הבאה. onProgress?.call(out.totalHashed); - shaSink.close(); - return digestSink.digest.toString(); + wholeSink?.close(); + return LogicalContentHashReport( + wholeHash: wholeDigest?.digest.toString(), + tableHashes: tableHashes, + tableBytes: tableBytes, + ); } /// קורא את שמות העמודות ממוינים אלפביתית, או null אם הטבלה אינה קיימת. @@ -116,12 +165,37 @@ class LogicalContentHasher { } } +/// תוצאת [LogicalContentHasher.computeReport] — hash כולל, hash לכל טבלה +/// ומספר הבתים שהוזרמו לכל טבלה (רמז התקדמות לריצה הבאה). +class LogicalContentHashReport { + /// ה-hash של כל זרם הטבלאות ברצף. null כשבוקשה תת-קבוצה (`only`), כי אז + /// לא הוזרמו כל הטבלאות. + final String? wholeHash; + + /// hash לכל טבלה שחושבה, לפי סדר ה-hash. + final Map tableHashes; + + /// מספר הבתים שהוזרמו ל-SHA עבור כל טבלה שחושבה. + final Map tableBytes; + + const LogicalContentHashReport({ + required this.wholeHash, + required this.tableHashes, + required this.tableBytes, + }); +} + /// חוצץ בינארי שמצטבר ומוזרם ל-SHA-256 מדי ~1MB. מחליף מיליוני `add` זעירים /// (בית/תא) בעדכונים גדולים בודדים, בלי לשנות את זרם הבתים. class _BufferedByteSink { _BufferedByteSink(this._sink, {this.onProgress}); - final ByteConversionSink _sink; + /// ה-digest הכולל. null כשבוקשה תת-קבוצה של טבלאות ואין hash כולל. + final ByteConversionSink? _sink; + + /// ה-digest של הטבלה הנוכחית — מקבל בדיוק את אותם טווחי בתים. + ByteConversionSink? _tableSink; + int _tableStart = 0; final void Function(int bytesHashed)? onProgress; static const int _capacity = 1 << 20; // 1MB static const int _progressInterval = 16 << 20; // 16MB @@ -132,6 +206,21 @@ class _BufferedByteSink { int get totalHashed => _totalHashed; + /// פותח טבלה חדשה: מרוקן את החוצץ כדי שה-digest של הטבלה יקבל בדיוק את + /// הבתים שלה — SHA-256 אינו תלוי בגבולות ה-chunks, אז ה-hash הכולל לא משתנה. + void beginTable(ByteConversionSink sink) { + flush(); + _tableSink = sink; + _tableStart = _totalHashed; + } + + /// סוגר את הטבלה הנוכחית ומחזיר את מספר הבתים שהוזרמו עבורה. + int endTable() { + flush(); + _tableSink = null; + return _totalHashed - _tableStart; + } + void addByte(int byte) { if (_length == _capacity) flush(); _buffer[_length++] = byte; @@ -144,7 +233,8 @@ class _BufferedByteSink { // חובה לפני, אחרת סדר הבתים ישתבש. if (len >= _capacity) { flush(); - _sink.add(bytes); + _sink?.add(bytes); + _tableSink?.add(bytes); _totalHashed += len; _reportIfDue(); return; @@ -159,7 +249,9 @@ class _BufferedByteSink { /// ניתן לעשות שימוש חוזר ב-[_buffer] מיד אחרי. void flush() { if (_length == 0) return; - _sink.add(Uint8List.sublistView(_buffer, 0, _length)); + final view = Uint8List.sublistView(_buffer, 0, _length); + _sink?.add(view); + _tableSink?.add(view); _length = 0; _reportIfDue(); } diff --git a/lib/src/services/patch_applier.dart b/lib/src/services/patch_applier.dart index 8827871..8cde5e3 100644 --- a/lib/src/services/patch_applier.dart +++ b/lib/src/services/patch_applier.dart @@ -33,6 +33,9 @@ class PatchApplyResult { final int migrations; final Map upserts; final Map deletes; + + /// ה-hash הכולל של ה-DB אחרי ה-apply. באימות חלקי לא חושב בפועל — זה + /// `toContentHash` שבמניפסט. final String resultHash; /// מזהי הספרים שתוכן האינדקס שלהם הושפע מה-patch — שינויים בטבלאות @@ -46,6 +49,16 @@ class PatchApplyResult { /// לרענון מלא. final Set booksTouched; + /// הטבלאות שאומתו בפועל מול `toTableContentHashes`. באימות DB מלא — ריק. + final List verifiedTables; + + /// הטבלאות שדולגו כי ה-patch לא נגע בהן וה-hash שלהן זהה ב-from וב-to. + /// הצרכן יכול לאמת אותן אחרי ה-commit (ראו [PatchApplier.verifyTableHashes]). + final List deferredTables; + + /// מספר הבתים שהוזרמו ל-SHA לכל טבלה שאומתה — רמז התקדמות לריצה הבאה. + final Map verifyTableBytes; + /// האם ה-patch שינה טבלאות שאינן מכוסות ב-[booksTouched] (מלבד schema_meta, /// שמתעדכן בכל patch, ו-line_ref/line_dh, שאינן תוכן חיפוש — ראו /// [kBooksTouchedTables]). כש-true, צרכן שהאינדקס שלו תלוי בטבלאות האלה @@ -65,6 +78,9 @@ class PatchApplyResult { required this.deletes, required this.resultHash, this.booksTouched = const {}, + this.verifiedTables = const [], + this.deferredTables = const [], + this.verifyTableBytes = const {}, }); } @@ -92,10 +108,14 @@ class PatchApplyException implements Exception { /// null בכשל שאינו hash; אחרת מציין איזה אימות hash נכשל. final PatchHashMismatchStage? hashMismatchStage; + /// שמות הטבלאות שה-hash שלהן לא תאם, כשהאימות היה לפי טבלאות. null אחרת. + final List? mismatchedTables; + const PatchApplyException( this.message, { bool isContentMismatch = false, this.hashMismatchStage, + this.mismatchedTables, }) : isContentMismatch = isContentMismatch || hashMismatchStage != null; @override String toString() => 'PatchApplyException: $message'; @@ -149,6 +169,13 @@ class PatchApplier { /// [verifyFromHash] — אם פעיל, מחשב את ה-hash המקומי לפני apply ומשווה ל- /// `fromContentHash` (יקר אך מזהה DB ששונה ידנית/corruption). /// [checkForeignKeys] — אם פעיל, מוודא ש-`foreign_key_check` לא גדל. + /// [verifyTableBytesHint] — בתים לכל טבלה מריצה קודמת, למד התקדמות מדויק + /// כשהמניפסט מאפשר אימות חלקי (ראו [PatchApplyResult.deferredTables]). + /// [enablePartialTableVerification] — מאפשר להחליף את אימות ה-DB המלא + /// באימות הטבלאות שהשתנו ובדחיית היתר. ברירת המחדל היא false כדי לשמר את + /// חוזה [apply] עבור צרכנים קיימים: חזרה מוצלחת פירושה שה-DB כולו אומת + /// לפני ה-commit. צרכן שמפעיל זאת חייב להריץ [verifyTableHashes] על + /// [PatchApplyResult.deferredTables] אחרי ה-commit. PatchApplyResult apply({ required String dbPath, required String patchPath, @@ -158,6 +185,8 @@ class PatchApplier { void Function(String stage)? onStage, void Function(int hashedBytes, int totalBytes)? onVerifyProgress, int? verifyTotalBytesHint, + Map? verifyTableBytesHint, + bool enablePartialTableVerification = false, }) { // ── preflight: שני סדרי ה-hash נפתרים לפני כל פתיחה/כתיבה — גרסת סכמה // לא מוכרת (from או to) זורקת כאן, גם כש-verifyFromHash כבוי. @@ -258,19 +287,60 @@ class PatchApplier { } onStage?.call('verifyToHash'); - if (verifyProgress != null) refreshTotal(); // ה-DB *אחרי* apply הוא בסכמת היעד — הסדר נבחר לפי toSchemaVersion. - final resultHash = hasher.compute( - db, - tableOrder: toOrder, - onProgress: verifyProgress, - ); - if (resultHash != manifest.toContentHash) { - throw PatchApplyException( - 'ה-hash אחרי apply ($resultHash) אינו תואם ל-toContentHash ' - '(${manifest.toContentHash})', - hashMismatchStage: PatchHashMismatchStage.toContentHash, + final toTables = enablePartialTableVerification + ? _tablesToVerify(db, manifest, fromOrder, toOrder) + : null; + final String resultHash; + var verifiedTables = const []; + var deferredTables = const []; + var verifyTableBytes = const {}; + if (toTables == null) { + if (verifyProgress != null) refreshTotal(); + resultHash = hasher.compute( + db, + tableOrder: toOrder, + onProgress: verifyProgress, ); + if (resultHash != manifest.toContentHash) { + throw PatchApplyException( + 'ה-hash אחרי apply ($resultHash) אינו תואם ל-toContentHash ' + '(${manifest.toContentHash})', + hashMismatchStage: PatchHashMismatchStage.toContentHash, + ); + } + } else { + final expected = manifest.toTableContentHashes!; + if (verifyProgress != null) { + totalBytes = _hintedTotal(verifyTableBytesHint, toTables); + if (totalBytes == 0) refreshTotal(); + } + final report = hasher.computeReport( + db, + tableOrder: toOrder, + only: toTables.toSet(), + onProgress: verifyProgress, + ); + final mismatched = [ + for (final t in toTables) + if (report.tableHashes[t] != expected[t]) t, + ]; + if (mismatched.isNotEmpty) { + throw PatchApplyException( + 'ה-hash אחרי apply אינו תואם ל-toTableContentHashes בטבלאות: ' + '${mismatched.join(', ')}', + hashMismatchStage: PatchHashMismatchStage.toContentHash, + mismatchedTables: mismatched, + ); + } + verifiedTables = toTables; + deferredTables = [ + for (final t in toOrder) + if (!report.tableHashes.containsKey(t)) t, + ]; + verifyTableBytes = report.tableBytes; + // אומת לפי טבלאות; ה-hash הכולל הצפוי הוא זה שבמניפסט. + resultHash = manifest.toContentHash; } onStage?.call('commit'); @@ -286,6 +356,9 @@ class PatchApplier { deletes: deletes, resultHash: resultHash, booksTouched: booksTouched, + verifiedTables: verifiedTables, + deferredTables: deferredTables, + verifyTableBytes: verifyTableBytes, ); } catch (_) { if (inTransaction) { @@ -304,6 +377,80 @@ class PatchApplier { } } + /// מאמת את [tables] ב-DB שב-[dbPath] (חיבור קריאה-בלבד) מול [expected], + /// ומחזיר את שמות הטבלאות שלא תאמו (ריק = הכול תואם). + List verifyTableHashes({ + required String dbPath, + required int schemaVersion, + required Map expected, + required List tables, + void Function(int hashedBytes, int totalBytes)? onProgress, + Map? tableBytesHint, + }) { + final order = hashTableOrderForSchemaVersion(schemaVersion); + final only = tables.toSet(); + var totalBytes = _hintedTotal(tableBytesHint, tables); + if (totalBytes == 0) totalBytes = File(dbPath).lengthSync(); + final db = sqlite3.sqlite3.open(dbPath, mode: sqlite3.OpenMode.readOnly); + try { + final report = hasher.computeReport( + db, + tableOrder: order, + only: only, + onProgress: onProgress == null + ? null + : (bytes) => onProgress(bytes, totalBytes), + ); + return [ + for (final t in order) + if (report.tableHashes.containsKey(t) && + report.tableHashes[t] != expected[t]) + t, + ]; + } finally { + db.close(); + } + } + + /// סכום רמזי הבתים עבור [tables]; 0 כשאין רמז שימושי. + int _hintedTotal(Map? hint, List tables) { + if (hint == null) return 0; + var sum = 0; + for (final t in tables) { + sum += hint[t] ?? 0; + } + return sum; + } + + /// רשימת הטבלאות לאימות לפי כלל האימות החלקי, או null כשהמניפסט אינו נושא + /// מפות עקביות — ואז מאמתים את ה-DB כולו כמו קודם. + List? _tablesToVerify(sqlite3.Database db, DeltaManifest manifest, + List fromOrder, List toOrder) { + final from = manifest.fromTableContentHashes; + final to = manifest.toTableContentHashes; + if (from == null || to == null) return null; + if (!_sameKeys(from.keys, fromOrder) || !_sameKeys(to.keys, toOrder)) { + return null; + } + return [ + for (final t in toOrder) + if (t == 'schema_meta' || + from[t] != to[t] || + _patchHasRows(db, 'upsert_$t') || + _patchHasRows(db, 'delete_$t')) + t, + ]; + } + + bool _sameKeys(Iterable keys, List order) => + keys.length == order.length && keys.toSet().containsAll(order); + + /// האם ל-patch יש טבלת [name] עם שורה אחת לפחות. + bool _patchHasRows(sqlite3.Database db, String name) { + if (!_hasTable(db, 'patch', name)) return false; + return db.select('SELECT 1 FROM patch."$name" LIMIT 1').isNotEmpty; + } + void _assertPatchCompatible(sqlite3.Database db, DeltaManifest manifest) { final schemaVersion = _readPatchMetaInt(db, 'schema_version'); if (schemaVersion == null) { diff --git a/pubspec.yaml b/pubspec.yaml index a0c67a6..ed2a324 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: seforim_library_updater description: >- לקוח Dart לצריכת הפצות הדלתא של Otzaria/SeforimLibrary — גילוי, תכנון מסלול, הורדה, אימות hash לוגי, והחלה אטומית של הפרשי DB. -version: 0.3.0 +version: 0.4.0 publish_to: none environment: diff --git a/test/delta_manifest_test.dart b/test/delta_manifest_test.dart index 03f9e2a..ce38363 100644 --- a/test/delta_manifest_test.dart +++ b/test/delta_manifest_test.dart @@ -61,6 +61,42 @@ void main() { expect(DeltaManifest.fromJson(json).patchFormatVersion, 4); }); + test('מפענח את שתי מפות ה-hash לפי טבלה', () { + final json = jsonDecode(validJson) as Map; + json['fromTableContentHashes'] = {'source': 'aa', 'book': 'bb'}; + json['toTableContentHashes'] = {'source': 'aa', 'book': 'cc'}; + final m = DeltaManifest.fromJson(json); + expect(m.fromTableContentHashes, {'source': 'aa', 'book': 'bb'}); + expect(m.toTableContentHashes, {'source': 'aa', 'book': 'cc'}); + }); + + test('מפות חסרות → null', () { + final m = + DeltaManifest.fromJson(jsonDecode(validJson) as Map); + expect(m.fromTableContentHashes, isNull); + expect(m.toTableContentHashes, isNull); + }); + + test('רק אחת מהמפות קיימת → שתיהן null', () { + final json = jsonDecode(validJson) as Map; + json['toTableContentHashes'] = {'source': 'aa'}; + final m = DeltaManifest.fromJson(json); + expect(m.toTableContentHashes, isNull); + expect(m.fromTableContentHashes, isNull); + }); + + test('מפה בטיפוס לא תקין → FormatException', () { + final json = jsonDecode(validJson) as Map; + json['fromTableContentHashes'] = ['source']; + expect(() => DeltaManifest.fromJson(json), throwsFormatException); + + json['fromTableContentHashes'] = {'source': 1}; + expect(() => DeltaManifest.fromJson(json), throwsFormatException); + + json['fromTableContentHashes'] = {'source': ''}; + expect(() => DeltaManifest.fromJson(json), throwsFormatException); + }); + test('סלחני לשדות לא מוכרים', () { final json = jsonDecode(validJson) as Map; json['someFutureField'] = {'a': 1}; diff --git a/test/logical_hash_contract.json b/test/logical_hash_contract.json new file mode 100644 index 0000000..83be4c9 --- /dev/null +++ b/test/logical_hash_contract.json @@ -0,0 +1,99 @@ +{ + "contractVersion": 1, + "description": "Shared oracle for the per-table logical content hash. tableHashes[t] = sha256 of exactly the bytes the whole-DB hasher emits for table t (\" table: \" prefix, then cols + rows when the table exists). wholeHash = sha256 of the concatenation of those per-table streams in hashTableOrder. Generated by tool/gen_logical_hash_fixture.dart (Dart hasher, byte-verified against the real v14/v15 chain).", + "schemaVersion": 5, + "hashTableOrder": [ + "source", + "author", + "topic", + "pub_place", + "pub_date", + "connection_type", + "generation", + "category", + "category_closure", + "tocText", + "book", + "book_topic", + "book_author", + "book_base_text", + "book_pub_place", + "book_pub_date", + "book_generation", + "tocEntry", + "line", + "line_toc", + "line_ref", + "line_dh", + "link", + "link_anchor", + "link_range", + "link_coverage", + "link_suppressed_side", + "book_has_links", + "book_version", + "version_line", + "book_acronym", + "alt_toc_structure", + "alt_toc_entry", + "line_alt_toc", + "default_commentator", + "default_targum", + "schema_meta" + ], + "setupSql": [ + "CREATE TABLE schema_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)", + "INSERT INTO schema_meta VALUES ('db_version','7'),('db_schema_version','5')", + "CREATE TABLE source (id INTEGER PRIMARY KEY, name TEXT)", + "INSERT INTO source VALUES (2,'ספריא'),(1,'אוצריא')", + "CREATE TABLE book (id INTEGER PRIMARY KEY, title TEXT NOT NULL, sourceId INTEGER, order_idx REAL, notes TEXT)", + "INSERT INTO book VALUES (10,'בְּרֵאשִׁית',1,1.5,NULL),(3,'שמות',2,2.0,''),(7,'ויקרא — פירוש \"רש\"\"י\"',1,-3.25,'multi\nline\ttext')", + "CREATE TABLE line (id INTEGER PRIMARY KEY, bookId INTEGER NOT NULL, lineIndex INTEGER NOT NULL, content TEXT NOT NULL, plainText TEXT)", + "INSERT INTO line VALUES (1,10,0,'בראשית ברא','בראשית ברא'),(2,10,1,'אלקים','אלקים'),(3,3,0,'ואלה שמות',NULL),(9007199254740993,7,0,'big id','big id')", + "CREATE TABLE book_author (bookId INTEGER NOT NULL, authorId INTEGER NOT NULL, PRIMARY KEY (bookId, authorId))", + "INSERT INTO book_author VALUES (10,2),(3,1),(10,1)", + "CREATE TABLE link (id INTEGER PRIMARY KEY, sourceLineId INTEGER, targetLineId INTEGER, payload BLOB)", + "INSERT INTO link VALUES (1,1,3,X'00FF1F02'),(2,2,NULL,X''),(3,3,1,NULL)", + "CREATE TABLE generation (id INTEGER PRIMARY KEY, name TEXT)" + ], + "wholeHash": "a5eb69248cf3993095f6b461cca4d9f43f1b1762e65880e82cf3dc180558a64d", + "tableHashes": { + "source": "feae8f08d36404f2acc5777efbde1f0a91f8296474d3dc30fb7de6652934331f", + "author": "b91e11e69a8aa4b551cc0aca7484cbae4a270dca18dfa2c8f8ea5ea56c7811ce", + "topic": "072a0d4c98c21c5864445853e6b2874ba40e947867605b6dca85b4595a4e4e72", + "pub_place": "0013b48a20d92ea396e191f3b9d2c57954ef3acfae76bdf7157361c8e40fe42a", + "pub_date": "3f71d2e92469ec534bcfdd6afda0ec6554b054d1a4b1c88d7ed9081650cbed77", + "connection_type": "70b742c585e22dadeecf124c2e51002acbfcaab968af29c8638db646ffb9afc9", + "generation": "343d7adb1c9872b1723b01fac89f519fbb43595c5720df0caff585195e9e3114", + "category": "f28dd42b38695c658ff5c46814f910d870f2f811ad6aef1d51a6288734df3079", + "category_closure": "4bd719e2aa0e0478c1f2fdd8796354e5121d3ca6a3873eb29f648a0b1c547973", + "tocText": "c2b1ad0aa7afb0a4940bccd51486a004d520946422a3c2348a691567bbcbe83d", + "book": "f38053ed411a24fd693a2f961f259913b247cf9e5d503d7949dc8c127b9cd624", + "book_topic": "243265784d1f1bd962ce0d7dd63660934cfd5705019b05da924bfd666fb18cd9", + "book_author": "2c03a9b88a35318809f78c97fc98717e7bd2d05ea5e8ef78b5efe59627512433", + "book_base_text": "75d60d32ebef72502ff9c735ad6ab46fcef558363b42bfa11b85285bb043e0d1", + "book_pub_place": "b1abdfe093d574e51f62f1a8e9ae7cfd546c4720b08ff0fbc8970be18e31212a", + "book_pub_date": "2fdbe7853a726a5029119d83cafdaa534e7a5719edce5200f1405791fb80e9f1", + "book_generation": "fa0bd0f7036440f8f9d6832cb2744788fcd9b8aeef6d932831121f8256c457d9", + "tocEntry": "173b2347ce3b5991b16bbcb671be1c85ec9e97ae07fad981a482d99a06876314", + "line": "969f679829820be08fa6863c0ecfba5729828982b5130f19542840bb61e56f49", + "line_toc": "b6e6b239b7225c3648eaef0ae9dadeeb6d2f089f753a8413963da42da2954bd4", + "line_ref": "37250a7ebbd202879d19ad917f30e841432b2694667113c7250af61fbb11b2d2", + "line_dh": "a26c54c96bc997dfbe2506f6be5c26b446d5eb3a4e599f516b168537ea8536b9", + "link": "13583355f564c0f5fdd5f48ec641ffdf5babf94e3c381e9c6981a8defae603c4", + "link_anchor": "e8598f806f1a5944c479f89eeb805008769bdd0b5cd1eb55815d0072260b1a4b", + "link_range": "ecca0e712990c1a2774a7ccdd41a4b65641b60b34a34a15b93df51101fcf485d", + "link_coverage": "312a05028b9737eb665ea473c8d4f25bc5ae9d64cd94ec12cd1b0e11511fba4e", + "link_suppressed_side": "fc09de9c824636d8754800aaca208c46df23bed8013fc785674b51ad20a9c96b", + "book_has_links": "bf2ba060761ba340e60035a4ffc88819e4b65f54c3bb0eb4464ff5bed1562f4a", + "book_version": "c258cad5f0f5ee7049722a2b6347a5404536fbb75c6969b48ec89d9e4ebc0c0c", + "version_line": "3176125b183852cd3127de75d9b4c28339e5e02cb0b0ded4984a981d7308ccc6", + "book_acronym": "93407a0a65f50c6d005971acbcf0608dd831d797957708698917be8dff19b669", + "alt_toc_structure": "9d34b982ea6a9d8c03111b1609b9a22df3f21ed35652355aea5f1b2ea2015807", + "alt_toc_entry": "4469de01fb8ef1481c008ef8787ab0a240b9d03bc750dd459ef706f00fbf8882", + "line_alt_toc": "89b47bc49be7fd7d90c27e5e779a49b80c3cf14a646be06760eb5e48b1573986", + "default_commentator": "c069e35fc927ede3d6b997e7da5cb0a64d7ae758c076585661ab7b54f69380ea", + "default_targum": "4672d9d1693961becf13e330344dc8b962bd0b94f353703f60a04d00cb037b31", + "schema_meta": "e8163db456e9c0e4386e4a7c579a8120d1d1a5b2b536eb3fd6ad96fe1fd5e403" + } +} diff --git a/test/logical_hash_contract_test.dart b/test/logical_hash_contract_test.dart new file mode 100644 index 0000000..e77cf10 --- /dev/null +++ b/test/logical_hash_contract_test.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:seforim_library_updater/src/services/logical_content_hasher.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite3; + +const _hasher = LogicalContentHasher(); + +/// ה-oracle המשותף ל-Dart ול-Kotlin. עותק זהה אות-באות נמצא בצד הקוטליני +/// (`generator/common/src/jvmTest/resources/`) ומושווה ב-CI. +void main() { + final fixture = jsonDecode( + File('test/logical_hash_contract.json').readAsStringSync(), + ) as Map; + final order = (fixture['hashTableOrder'] as List).cast(); + final expectedTables = + (fixture['tableHashes'] as Map).cast(); + + sqlite3.Database buildDb() { + final db = sqlite3.sqlite3.openInMemory(); + for (final sql in (fixture['setupSql'] as List).cast()) { + db.execute(sql); + } + return db; + } + + group('logical hash contract (oracle)', () { + test('wholeHash ו-hash לכל טבלה תואמים ל-fixture', () { + final db = buildDb(); + final report = _hasher.computeReport(db, tableOrder: order); + expect(report.wholeHash, fixture['wholeHash']); + expect(report.tableHashes.keys, order); + for (final t in order) { + expect(report.tableHashes[t], expectedTables[t], reason: 'טבלה $t'); + } + db.close(); + }); + + test('compute() זהה ל-computeReport().wholeHash', () { + final db = buildDb(); + expect( + _hasher.compute(db, tableOrder: order), + _hasher.computeReport(db, tableOrder: order).wholeHash, + ); + db.close(); + }); + + test('זרם הטבלה הבודדת זהה ל-hash שבדוח (שרשור = הכולל)', () { + final db = buildDb(); + final report = _hasher.computeReport(db, tableOrder: order); + for (final t in order) { + expect(_hasher.compute(db, tableOrder: [t]), report.tableHashes[t], + reason: 'טבלה $t'); + } + db.close(); + }); + + test('תת-קבוצה: wholeHash ריק, רק הטבלאות שבוקשו', () { + final db = buildDb(); + final full = _hasher.computeReport(db, tableOrder: order); + final subset = + _hasher.computeReport(db, tableOrder: order, only: {'book', 'line'}); + expect(subset.wholeHash, isNull); + expect(subset.tableHashes.keys, ['book', 'line']); + expect(subset.tableHashes['book'], full.tableHashes['book']); + expect(subset.tableHashes['line'], full.tableHashes['line']); + expect(subset.tableBytes['book'], full.tableBytes['book']); + db.close(); + }); + + test('סך הבתים לכל הטבלאות שווה לסך הבתים של המעבר הכולל', () { + final db = buildDb(); + var streamed = 0; + final report = _hasher.computeReport(db, + tableOrder: order, onProgress: (bytes) => streamed = bytes); + final sum = report.tableBytes.values.fold(0, (a, b) => a + b); + expect(sum, streamed); + db.close(); + }); + }); +} diff --git a/test/patch_applier_test.dart b/test/patch_applier_test.dart index b32003e..b94ff6f 100644 --- a/test/patch_applier_test.dart +++ b/test/patch_applier_test.dart @@ -21,6 +21,18 @@ String _hashWithOrder(String dbPath, List order) { } } +/// ה-hash לכל טבלה בסדר סכמה-2 — הצורה שבה המניפסט נושא את המפות. +Map _tableHashesOf(String dbPath) { + final db = sqlite3.sqlite3.open(dbPath, mode: sqlite3.OpenMode.readOnly); + try { + return _hasher + .computeReport(db, tableOrder: kHashTableOrderSchema2) + .tableHashes; + } finally { + db.close(); + } +} + String _hashOf(String dbPath) { final db = sqlite3.sqlite3.open(dbPath, mode: sqlite3.OpenMode.readOnly); try { @@ -42,6 +54,8 @@ DeltaManifest _manifest({ int toSchema = 2, int? patchFormat, bool omitPatchFormat = false, + Map? fromTables, + Map? toTables, }) => DeltaManifest( fromVersion: from, @@ -52,6 +66,8 @@ DeltaManifest _manifest({ omitPatchFormat ? null : (patchFormat ?? (toSchema >= 4 ? 4 : null)), fromContentHash: fromHash, toContentHash: toHash, + fromTableContentHashes: fromTables, + toTableContentHashes: toTables, patchFiles: const [ PatchFileEntry( file: 'p.db.zst', @@ -1097,7 +1113,8 @@ void main() { test('המיגרציה רצה לפני snapshot מלא ומעדכנת שורות עם PK קיים', () { final base = '${tmp.path}/schema4_v26.db'; final baseDb = sqlite3.sqlite3.open(base); - baseDb.execute('CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT)'); + baseDb.execute( + 'CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT)'); baseDb.execute("INSERT INTO schema_meta VALUES " "('db_version','26'),('db_schema_version','4')"); baseDb.execute('CREATE TABLE line_dh (' @@ -1110,7 +1127,8 @@ void main() { final expected = '${tmp.path}/schema5_v27.db'; final expectedDb = sqlite3.sqlite3.open(expected); - expectedDb.execute('CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT)'); + expectedDb.execute( + 'CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT)'); expectedDb.execute("INSERT INTO schema_meta VALUES " "('db_version','27'),('db_schema_version','5')"); expectedDb.execute('CREATE TABLE line_dh (' @@ -1124,10 +1142,12 @@ void main() { final patch = '${tmp.path}/schema4-5.db'; final patchDb = sqlite3.sqlite3.open(patch); - patchDb.execute('CREATE TABLE patch_meta (key TEXT PRIMARY KEY, value TEXT)'); + patchDb.execute( + 'CREATE TABLE patch_meta (key TEXT PRIMARY KEY, value TEXT)'); patchDb.execute("INSERT INTO patch_meta VALUES " "('schema_version','4'),('from_version','26'),('to_version','27')"); - patchDb.execute('CREATE TABLE migrations (version INTEGER PRIMARY KEY, sql TEXT)'); + patchDb.execute( + 'CREATE TABLE migrations (version INTEGER PRIMARY KEY, sql TEXT)'); patchDb.execute('INSERT INTO migrations VALUES (1, ?)', [ "ALTER TABLE \"line_dh\" ADD COLUMN \"dhDisplay\" TEXT NOT NULL DEFAULT ''" ]); @@ -1171,6 +1191,422 @@ void main() { }); }); + group('אימות חלקי לפי טבלאות', () { + // כל ה-DB-ים כאן בסכמה-2, כדי שהמפות במניפסט יתאימו למפתחות + // [kHashTableOrderSchema2] — התנאי לבחירת המסלול החלקי. + String buildDb( + String name, { + required int version, + List sourceRows = const [], + List? bookRows, + }) { + final path = '${tmp.path}/$name.db'; + final db = sqlite3.sqlite3.open(path); + db.execute('CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT)'); + db.execute("INSERT INTO schema_meta VALUES ('db_version','$version')," + "('db_schema_version','2')"); + db.execute('CREATE TABLE source (id INTEGER PRIMARY KEY, name TEXT)'); + for (final r in sourceRows) { + db.execute('INSERT INTO source VALUES (?,?)', [r[0], r[1]]); + } + if (bookRows != null) { + db.execute('CREATE TABLE book (id INTEGER PRIMARY KEY, title TEXT)'); + for (final r in bookRows) { + db.execute('INSERT INTO book VALUES (?,?)', [r[0], r[1]]); + } + } + db.close(); + return path; + } + + test('מפות hash אינן משנות את אימות ברירת המחדל המלא', () { + final local = buildDb('default_full_local', version: 1, sourceRows: [ + [1, 'aleph'], + ], bookRows: [ + [1, 'סטה'], + ]); + final canonFrom = buildDb('default_full_from', version: 1, sourceRows: [ + [1, 'aleph'], + ], bookRows: [ + [1, 'קנוני'], + ]); + final canonTo = buildDb('default_full_to', version: 2, sourceRows: [ + [1, 'aleph'], + [2, 'bet'], + ], bookRows: [ + [1, 'קנוני'], + ]); + final before = _hashOf(local); + final patch = buildPatchDb(from: 1, to: 2, upsertSource: [ + [2, 'bet'], + ]); + final manifest = _manifest( + from: 1, + to: 2, + fromHash: _hashOf(canonFrom), + toHash: _hashOf(canonTo), + fromTables: _tableHashesOf(canonFrom), + toTables: _tableHashesOf(canonTo), + ); + + expect( + () => _applier.apply( + dbPath: local, + patchPath: patch, + manifest: manifest, + verifyFromHash: false, + ), + throwsA(isA() + .having((e) => e.mismatchedTables, 'mismatchedTables', isNull)), + ); + expect(_hashOf(local), before); + }); + + test('מאמת רק את הטבלאות שהשתנו ומדווח על השאר כדחויות', () { + final base = buildDb('partial_base', version: 1, sourceRows: [ + [1, 'aleph'], + [2, 'bet'], + ]); + final expected = buildDb('partial_expected', version: 2, sourceRows: [ + [1, 'ALEPH'], + [2, 'bet'], + ]); + final patch = buildPatchDb(from: 1, to: 2, upsertSource: [ + [1, 'ALEPH'], + ]); + final manifest = _manifest( + from: 1, + to: 2, + fromHash: _hashOf(base), + toHash: _hashOf(expected), + fromTables: _tableHashesOf(base), + toTables: _tableHashesOf(expected), + ); + + final result = _applier.apply( + dbPath: base, + patchPath: patch, + manifest: manifest, + enablePartialTableVerification: true, + ); + + // source השתנתה (וגם נגע בה ה-patch), schema_meta תמיד באימות. + expect(result.verifiedTables, ['source', 'schema_meta']); + expect(result.verifyTableBytes.keys, ['source', 'schema_meta']); + expect(result.deferredTables, isNot(contains('source'))); + expect(result.deferredTables, contains('book')); + expect( + result.verifiedTables.length + result.deferredTables.length, + kHashTableOrderSchema2.length, + ); + expect(result.resultHash, manifest.toContentHash); + expect(_hashOf(base), manifest.toContentHash); + }); + + test('טבלה שה-patch נגע בה בלי לשנות את ה-hash שלה עדיין מאומתת', () { + final base = buildDb('touched_same_base', version: 1, sourceRows: [ + [1, 'aleph'], + ]); + final expected = + buildDb('touched_same_expected', version: 2, sourceRows: [ + [1, 'aleph'], + ]); + // upsert של שורה זהה: from[source] == to[source], אך יש שורות ב-patch. + final patch = buildPatchDb(from: 1, to: 2, upsertSource: [ + [1, 'aleph'], + ]); + final manifest = _manifest( + from: 1, + to: 2, + fromHash: _hashOf(base), + toHash: _hashOf(expected), + fromTables: _tableHashesOf(base), + toTables: _tableHashesOf(expected), + ); + + final result = _applier.apply( + dbPath: base, + patchPath: patch, + manifest: manifest, + enablePartialTableVerification: true, + ); + + expect(result.verifiedTables, contains('source')); + expect(result.deferredTables, isNot(contains('source'))); + }); + + test('טבלה שה-hash שלה שונה במניפסט מאומתת גם בלי שורות ב-patch', () { + final base = buildDb('hash_only_base', version: 1, bookRows: [ + [1, 'בראשית'], + ]); + final expected = buildDb('hash_only_expected', version: 2, bookRows: [ + [1, 'בראשית'], + ]); + final patch = buildPatchDb(from: 1, to: 2); + // from[book] "אחר" מדמה שינוי שהגיע ממיגרציה ולא משורות patch. + final fromTables = Map.of(_tableHashesOf(base))..['book'] = 'stale'; + final manifest = _manifest( + from: 1, + to: 2, + fromHash: _hashOf(base), + toHash: _hashOf(expected), + fromTables: fromTables, + toTables: _tableHashesOf(expected), + ); + + final result = _applier.apply( + dbPath: base, + patchPath: patch, + manifest: manifest, + enablePartialTableVerification: true, + ); + + expect(result.verifiedTables, contains('book')); + expect(result.deferredTables, isNot(contains('book'))); + }); + + test('רמז הבתים לכל טבלה קובע את ה-total של מד ההתקדמות', () { + final base = buildDb('hint_base', version: 1, sourceRows: [ + [1, 'aleph'], + ]); + final expected = buildDb('hint_expected', version: 2, sourceRows: [ + [1, 'aleph'], + [2, 'bet'], + ]); + final patch = buildPatchDb(from: 1, to: 2, upsertSource: [ + [2, 'bet'], + ]); + final manifest = _manifest( + from: 1, + to: 2, + fromHash: _hashOf(base), + toHash: _hashOf(expected), + fromTables: _tableHashesOf(base), + toTables: _tableHashesOf(expected), + ); + + var lastTotal = -1; + _applier.apply( + dbPath: base, + patchPath: patch, + manifest: manifest, + verifyFromHash: false, + enablePartialTableVerification: true, + onVerifyProgress: (_, total) => lastTotal = total, + verifyTableBytesHint: const { + 'source': 100, + 'schema_meta': 50, + 'book': 999999, + }, + ); + expect(lastTotal, 150); + }); + + test('אי-התאמה בטבלה שה-patch נגע בה מדווחת בשמה ומגלגלת לאחור', () { + final base = buildDb('mismatch_base', version: 1, sourceRows: [ + [1, 'aleph'], + ]); + final expected = buildDb('mismatch_expected', version: 2, sourceRows: [ + [1, 'aleph'], + [2, 'bet'], + ]); + final before = _hashOf(base); + final patch = buildPatchDb(from: 1, to: 2, upsertSource: [ + [2, 'bet'], + ]); + final toTables = _tableHashesOf(expected) + ..['source'] = List.filled(64, '0').join(); + final manifest = _manifest( + from: 1, + to: 2, + fromHash: before, + toHash: _hashOf(expected), + fromTables: _tableHashesOf(base), + toTables: toTables, + ); + + expect( + () => _applier.apply( + dbPath: base, + patchPath: patch, + manifest: manifest, + enablePartialTableVerification: true, + ), + throwsA(isA() + .having( + (e) => e.hashMismatchStage, + 'hashMismatchStage', + PatchHashMismatchStage.toContentHash, + ) + .having((e) => e.mismatchedTables, 'mismatchedTables', ['source'])), + ); + expect(_hashOf(base), before); + }); + + test('סחיפה בטבלה שלא נגעו בה אינה מפילה את ה-apply ונשארת דחויה', () { + final local = buildDb('drift_local', version: 1, sourceRows: [ + [1, 'aleph'], + ], bookRows: [ + [1, 'סטה'], + ]); + final canonFrom = buildDb('drift_canon_from', version: 1, sourceRows: [ + [1, 'aleph'], + ], bookRows: [ + [1, 'קנוני'], + ]); + final canonTo = buildDb('drift_canon_to', version: 2, sourceRows: [ + [1, 'aleph'], + [2, 'bet'], + ], bookRows: [ + [1, 'קנוני'], + ]); + final patch = buildPatchDb(from: 1, to: 2, upsertSource: [ + [2, 'bet'], + ]); + final manifest = _manifest( + from: 1, + to: 2, + fromHash: _hashOf(canonFrom), + toHash: _hashOf(canonTo), + fromTables: _tableHashesOf(canonFrom), + toTables: _tableHashesOf(canonTo), + ); + + final result = _applier.apply( + dbPath: local, + patchPath: patch, + manifest: manifest, + verifyFromHash: false, + enablePartialTableVerification: true, + ); + expect(result.deferredTables, contains('book')); + + final drifted = _applier.verifyTableHashes( + dbPath: local, + schemaVersion: 2, + expected: manifest.toTableContentHashes!, + tables: result.deferredTables, + ); + expect(drifted, ['book']); + expect( + _applier.verifyTableHashes( + dbPath: local, + schemaVersion: 2, + expected: manifest.toTableContentHashes!, + tables: result.verifiedTables, + ), + isEmpty, + ); + }); + + test('מניפסט ישן ללא מפות → אימות מלא תופס את אותה סחיפה', () { + final local = buildDb('old_local', version: 1, sourceRows: [ + [1, 'aleph'], + ], bookRows: [ + [1, 'סטה'], + ]); + final canonTo = buildDb('old_canon_to', version: 2, sourceRows: [ + [1, 'aleph'], + [2, 'bet'], + ], bookRows: [ + [1, 'קנוני'], + ]); + final before = _hashOf(local); + final patch = buildPatchDb(from: 1, to: 2, upsertSource: [ + [2, 'bet'], + ]); + final manifest = _manifest( + from: 1, + to: 2, + fromHash: before, + toHash: _hashOf(canonTo), + ); + + expect( + () => _applier.apply( + dbPath: local, + patchPath: patch, + manifest: manifest, + verifyFromHash: false, + ), + throwsA(isA() + .having( + (e) => e.hashMismatchStage, + 'hashMismatchStage', + PatchHashMismatchStage.toContentHash, + ) + .having((e) => e.mismatchedTables, 'mismatchedTables', isNull)), + ); + expect(_hashOf(local), before); + }); + + test('מפות לא עקביות → אימות מלא תופס את הסחיפה', () { + final local = buildDb('bad_maps_local', version: 1, sourceRows: [ + [1, 'aleph'], + ], bookRows: [ + [1, 'סטה'], + ]); + final canonTo = buildDb('bad_maps_canon_to', version: 2, sourceRows: [ + [1, 'aleph'], + [2, 'bet'], + ], bookRows: [ + [1, 'קנוני'], + ]); + final full = _tableHashesOf(canonTo); + final patch = buildPatchDb(from: 1, to: 2, upsertSource: [ + [2, 'bet'], + ]); + final manifest = _manifest( + from: 1, + to: 2, + fromHash: _hashOf(local), + toHash: _hashOf(canonTo), + // חלקיות במפתחות — לא סדר ה-hash המלא, ולכן חוזרים לאימות מלא. + fromTables: {'source': full['source']!}, + toTables: {'source': full['source']!}, + ); + + expect( + () => _applier.apply( + dbPath: local, + patchPath: patch, + manifest: manifest, + verifyFromHash: false, + ), + throwsA(isA() + .having((e) => e.mismatchedTables, 'mismatchedTables', isNull)), + ); + }); + + test('מפות לא עקביות על DB תקין → עובר בלי טבלאות דחויות', () { + final base = buildDb('bad_maps_ok_base', version: 1, sourceRows: [ + [1, 'aleph'], + ]); + final expected = buildDb('bad_maps_ok_expected', version: 2, sourceRows: [ + [1, 'aleph'], + [2, 'bet'], + ]); + final patch = buildPatchDb(from: 1, to: 2, upsertSource: [ + [2, 'bet'], + ]); + final manifest = _manifest( + from: 1, + to: 2, + fromHash: _hashOf(base), + toHash: _hashOf(expected), + fromTables: const {'source': 'aa'}, + toTables: const {'source': 'aa'}, + ); + + final result = + _applier.apply(dbPath: base, patchPath: patch, manifest: manifest); + expect(result.resultHash, manifest.toContentHash); + expect(result.deferredTables, isEmpty); + expect(result.verifiedTables, isEmpty); + expect(result.verifyTableBytes, isEmpty); + }); + }); + group('hashTableOrderForSchemaVersion', () { test('סכמה-1 → סדר 33 הישן (ללא book_base_text)', () { expect(hashTableOrderForSchemaVersion(1), same(kHashTableOrderSchema1)); diff --git a/tool/gen_logical_hash_fixture.dart b/tool/gen_logical_hash_fixture.dart new file mode 100644 index 0000000..47ce0e5 --- /dev/null +++ b/tool/gen_logical_hash_fixture.dart @@ -0,0 +1,55 @@ +// מחולל את test/logical_hash_contract.json — ה-oracle המשותף ל-Dart ול-Kotlin +// עבור hash-לפי-טבלה. הריצו מחדש רק כששינוי בחוזה ה-hash מכוון. +import 'dart:convert'; +import 'dart:io'; + +import 'package:seforim_library_updater/src/models/patch_table_spec.dart'; +import 'package:seforim_library_updater/src/services/logical_content_hasher.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite3; + +const setupSql = [ + 'CREATE TABLE schema_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)', + "INSERT INTO schema_meta VALUES ('db_version','7'),('db_schema_version','5')", + 'CREATE TABLE source (id INTEGER PRIMARY KEY, name TEXT)', + "INSERT INTO source VALUES (2,'ספריא'),(1,'אוצריא')", + 'CREATE TABLE book (id INTEGER PRIMARY KEY, title TEXT NOT NULL, sourceId INTEGER, order_idx REAL, notes TEXT)', + "INSERT INTO book VALUES (10,'בְּרֵאשִׁית',1,1.5,NULL),(3,'שמות',2,2.0,''),(7,'ויקרא — פירוש \"רש\"\"י\"',1,-3.25,'multi\nline\ttext')", + 'CREATE TABLE line (id INTEGER PRIMARY KEY, bookId INTEGER NOT NULL, lineIndex INTEGER NOT NULL, content TEXT NOT NULL, plainText TEXT)', + "INSERT INTO line VALUES (1,10,0,'בראשית ברא','בראשית ברא'),(2,10,1,'אלקים','אלקים'),(3,3,0,'ואלה שמות',NULL),(9007199254740993,7,0,'big id','big id')", + 'CREATE TABLE book_author (bookId INTEGER NOT NULL, authorId INTEGER NOT NULL, PRIMARY KEY (bookId, authorId))', + 'INSERT INTO book_author VALUES (10,2),(3,1),(10,1)', + 'CREATE TABLE link (id INTEGER PRIMARY KEY, sourceLineId INTEGER, targetLineId INTEGER, payload BLOB)', + "INSERT INTO link VALUES (1,1,3,X'00FF1F02'),(2,2,NULL,X''),(3,3,1,NULL)", + 'CREATE TABLE generation (id INTEGER PRIMARY KEY, name TEXT)', +]; + +void main() { + final db = sqlite3.sqlite3.openInMemory(); + for (final sql in setupSql) { + db.execute(sql); + } + const hasher = LogicalContentHasher(); + final tableHashes = { + for (final t in kHashTableOrder) t: hasher.compute(db, tableOrder: [t]), + }; + final whole = hasher.compute(db, tableOrder: kHashTableOrder); + final fixture = { + 'contractVersion': 1, + 'description': + 'Shared oracle for the per-table logical content hash. tableHashes[t] = ' + 'sha256 of exactly the bytes the whole-DB hasher emits for table t ' + '(" table: " prefix, then cols + rows when the table exists). ' + 'wholeHash = sha256 of the concatenation of those per-table streams ' + 'in hashTableOrder. Generated by tool/gen_logical_hash_fixture.dart ' + '(Dart hasher, byte-verified against the real v14/v15 chain).', + 'schemaVersion': 5, + 'hashTableOrder': kHashTableOrder, + 'setupSql': setupSql, + 'wholeHash': whole, + 'tableHashes': tableHashes, + }; + final json = const JsonEncoder.withIndent(' ').convert(fixture); + File('test/logical_hash_contract.json').writeAsStringSync('$json\n'); + db.close(); + stdout.writeln('whole=$whole tables=${tableHashes.length}'); +}