Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Changelog

## 0.4.0

hash תוכן לוגי לכל טבלה — אימות אחרי apply רק על הטבלאות שהשתנו, במקום
הזרמת כל ה-DB (‏~80% מ-6GB) בכל צעד בשרשרת.

- `LogicalContentHasher.computeReport` — מעבר יחיד שמחזיר גם את ה-hash הכולל
וגם `tableHashes`/`tableBytes` לכל טבלה. `tableHash(t)` הוא sha256 של בדיוק
הבתים שהטבלה תורמת לזרם הכולל (כולל הקידומת `" table:<t> "`), ולכן
`wholeHash == sha256` של שרשור הזרמים. `compute` נשאר עם אותה חתימה ומאציל.
`only` מבקש תת-קבוצה של טבלאות, ואז `wholeHash` הוא null.
- `DeltaManifest`: שדות אופציונליים `fromTableContentHashes` ו-
`toTableContentHashes` (מפה `<table> -> <hex>`). שניהם יחד או אף אחד —
מניפסט שנושא רק אחד מהם נקרא כאילו אין מפות; ערך קיים שאינו מפה של מחרוזות
לא ריקות זורק `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`
Expand Down
4 changes: 3 additions & 1 deletion lib/seforim_library_updater.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
40 changes: 40 additions & 0 deletions lib/src/models/delta_manifest.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ class DeltaManifest extends Equatable {
/// קבצי ה-patch להורדה והחלה (כרגע תמיד קובץ אחד).
final List<PatchFileEntry> patchFiles;

/// hash לכל טבלה של ה-DB *לפני* ה-patch, לפי סדר ה-hash של
/// [fromSchemaVersion]. null במניפסט ישן, או כשרק אחת משתי המפות קיימת.
final Map<String, String>? fromTableContentHashes;

/// hash לכל טבלה של ה-DB *אחרי* ה-patch, לפי סדר ה-hash של
/// [toSchemaVersion]. מאפשר ללקוח לאמת רק את הטבלאות שהשתנו.
final Map<String, String>? toTableContentHashes;

/// שדות אופציונליים עתידיים — נשמרים אם קיימים, אך אינם חובה.
final List<int> booksTouched;
final String? catalogBlobName;
Expand All @@ -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,
Expand All @@ -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'),
Expand All @@ -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<String, dynamic>))
.toList(growable: false),
Expand All @@ -143,6 +163,8 @@ class DeltaManifest extends Equatable {
patchFormatVersion,
fromContentHash,
toContentHash,
fromTableContentHashes,
toTableContentHashes,
patchFiles,
booksTouched,
catalogBlobName,
Expand All @@ -165,6 +187,24 @@ int _requireInt(Map<String, dynamic> json, String key) {
return value.toInt();
}

/// מפענח מפת `<table> -> <hex>` אופציונלית. null כשהשדה חסר; [FormatException]
/// כשהוא קיים אך אינו מפה של מחרוזות לא ריקות.
Map<String, String>? _optionalStringMap(Map<String, dynamic> 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 = <String, String>{};
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<String, dynamic> json, String key) {
if (!json.containsKey(key) || json[key] == null) return null;
final value = json[key];
Expand Down
156 changes: 124 additions & 32 deletions lib/src/services/logical_content_hasher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,49 +42,98 @@ class LogicalContentHasher {
/// [tableOrder] — סדר הטבלאות לשקלול ב-hash. ברירת מחדל: [kHashTableOrder]
/// (34 טבלאות, סכמה-2). ה-caller בוחר את הסדר לפי גרסת הסכמה של ה-DB.
String compute(sqlite3.Database db,
{List<String> 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<String> tableOrder = kHashTableOrder,
Set<String>? 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<String> tableOrder,
required bool includeTableHashes,
Set<String>? 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 = <String, String>{};
final tableBytes = <String, int>{};

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 אם הטבלה אינה קיימת.
Expand Down Expand Up @@ -116,12 +165,37 @@ class LogicalContentHasher {
}
}

/// תוצאת [LogicalContentHasher.computeReport] — hash כולל, hash לכל טבלה
/// ומספר הבתים שהוזרמו לכל טבלה (רמז התקדמות לריצה הבאה).
class LogicalContentHashReport {
/// ה-hash של כל זרם הטבלאות ברצף. null כשבוקשה תת-קבוצה (`only`), כי אז
/// לא הוזרמו כל הטבלאות.
final String? wholeHash;

/// hash לכל טבלה שחושבה, לפי סדר ה-hash.
final Map<String, String> tableHashes;

/// מספר הבתים שהוזרמו ל-SHA עבור כל טבלה שחושבה.
final Map<String, int> 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
Expand All @@ -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;
Expand All @@ -144,7 +233,8 @@ class _BufferedByteSink {
// חובה לפני, אחרת סדר הבתים ישתבש.
if (len >= _capacity) {
flush();
_sink.add(bytes);
_sink?.add(bytes);
_tableSink?.add(bytes);
_totalHashed += len;
_reportIfDue();
return;
Expand All @@ -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();
}
Expand Down
Loading
Loading