diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 6ead9b77..7349e995 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -13,6 +13,8 @@ jobs: env: KOTLIN_FIXTURE: generator/common/src/jvmTest/resources/patch_tables_contract.json DART_FIXTURE: seforim-library-updater/test/patch_tables_contract.json + KOTLIN_HASH_FIXTURE: generator/common/src/jvmTest/resources/logical_hash_contract.json + DART_HASH_FIXTURE: seforim-library-updater/test/logical_hash_contract.json KOTLIN_VISIBILITY_FIXTURE: generator/sefariasqlite/src/jvmTest/resources/link_visibility_contract_v1.json SEFARIA_VISIBILITY_FIXTURE: sefaria-export/link_visibility_contract_v1.json steps: @@ -63,6 +65,8 @@ jobs: run: | test -f "$KOTLIN_FIXTURE" test -f "$DART_FIXTURE" + test -f "$KOTLIN_HASH_FIXTURE" + test -f "$DART_HASH_FIXTURE" test -f "$KOTLIN_VISIBILITY_FIXTURE" test -f "$SEFARIA_VISIBILITY_FIXTURE" @@ -70,6 +74,7 @@ jobs: - name: cmp fixtures (byte-identical) run: | cmp "$KOTLIN_FIXTURE" "$DART_FIXTURE" + cmp "$KOTLIN_HASH_FIXTURE" "$DART_HASH_FIXTURE" cmp "$KOTLIN_VISIBILITY_FIXTURE" "$SEFARIA_VISIBILITY_FIXTURE" # cmp לבדו תופס רק סטייה בין שני ה-fixtures. שינוי ברשימת הטבלאות בצד Kotlin @@ -89,4 +94,4 @@ jobs: run: chmod +x gradlew - name: Run Kotlin patch-tables contract test - run: ./gradlew :generator-common:jvmTest --tests '*PatchTablesContractTest*' --no-daemon + run: ./gradlew :generator-common:jvmTest --tests '*PatchTablesContractTest*' --tests '*LogicalHashContractTest*' --no-daemon diff --git a/DELTA_UPDATE_WORKFLOW.md b/DELTA_UPDATE_WORKFLOW.md index c6c2e937..4464de60 100644 --- a/DELTA_UPDATE_WORKFLOW.md +++ b/DELTA_UPDATE_WORKFLOW.md @@ -194,6 +194,42 @@ copy(v1) → apply(patch) → LogicalContentHasher.compute() == hash(v2) If this fails, `producePatchAndVerify` exits non-zero and the release is NOT published. +### 1.4 Per-table content hashes + +`LogicalContentHasher.computeReport()` returns, in one pass, both the whole-DB +hash and `tableHashes` — for each table of the hash table order, +`sha256` of exactly the bytes that table contributed to the whole stream +(the `" table: "` prefix included; an absent table hashes to its prefix +alone). Therefore +`wholeHash == sha256(stream(t1) ‖ stream(t2) ‖ …)`. + +The pipeline ships both maps in the manifest: + +```json +"fromTableContentHashes": { "source": "", "author": "", … }, +"toTableContentHashes": { "source": "", … } +``` + +- Keys = every table of the hash table order for `fromSchemaVersion` / + `toSchemaVersion` respectively, in that order. Both maps are present or + both absent; `fromContentHash` / `toContentHash` stay required, so old + clients are unaffected. +- The verify-apply gate compares the applied per-table hashes with new's, on + top of the whole-hash check — the maps a release publishes are gated as + hard as the hash the client verifies against. + +**Why the client cares:** whole-DB verification streams ~80% of a 6 GB file +after every applied patch. With these maps the client verifies only the +tables whose `from` and `to` hashes differ, plus every table the patch has +upsert/delete rows for, plus `schema_meta`; the rest are deferred to a +read-only pass after the update is committed and the library is readable +again. Drift found in a deferred table is reported, not rolled back. + +The oracle for both sides is `logical_hash_contract.json` — byte-identical in +`generator/common/src/jvmTest/resources/` and the updater's `test/`, cmp'd by +`contract.yml`. Do not regenerate it unless both sides fail identically and +the change is deliberate. + --- ## 2. CDN / static-host layout diff --git a/delta-updater/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/deltaupdater/Manifest.kt b/delta-updater/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/deltaupdater/Manifest.kt index 3e344b98..967bc841 100644 --- a/delta-updater/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/deltaupdater/Manifest.kt +++ b/delta-updater/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/deltaupdater/Manifest.kt @@ -17,6 +17,12 @@ data class DeltaManifest( val toSchemaVersion: Int, val fromContentHash: String, val toContentHash: String, + /** + * Per-table logical hashes, keyed by the hash table order of + * from/toSchemaVersion. Empty on manifests produced before this field. + */ + val fromTableContentHashes: Map = emptyMap(), + val toTableContentHashes: Map = emptyMap(), /** * Books touched / added / removed / renamed between fromVersion and toVersion. * Empty when the diff has no per-book scope (e.g. lookup-only changes). diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt index 07502b82..eea5595c 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalContentHasher.kt @@ -24,40 +24,71 @@ class LogicalContentHasher( private val logger: Logger = Logger.withTag("LogicalContentHasher"), ) { - fun compute(conn: Connection): String { - val md = MessageDigest.getInstance("SHA-256") - for (table in tables) { - md.update(" table:$table ".toByteArray()) - val cols = readColumnsCanonical(conn, table) ?: continue // table not present - md.update(cols.joinToString(",", prefix = "cols:").toByteArray()) - md.update(byteArrayOf(0x00)) + /** + * Whole-DB hash plus the per-table digest of exactly the bytes each table + * contributed to it, in hash table order. + */ + data class Report( + val wholeHash: String, + val tableHashes: LinkedHashMap, + ) + + fun compute(conn: Connection): String = computeReport(conn).wholeHash + + /** + * One pass, two digests: whole-DB and per-table (reset at each table boundary), + * so the client can verify only the tables a patch could have touched. + */ + fun computeReport(conn: Connection): Report { + val whole = MessageDigest.getInstance("SHA-256") + val table = MessageDigest.getInstance("SHA-256") + val sink = DualDigest(whole, table) + val tableHashes = LinkedHashMap() + for (t in tables) { + sink.update(" table:$t ".toByteArray()) + val cols = readColumnsCanonical(conn, t) + if (cols == null) { // table not present — its stream is just the prefix + tableHashes[t] = hex(table.digest()) + continue + } + sink.update(cols.joinToString(",", prefix = "cols:").toByteArray()) + sink.update(byteArrayOf(0x00)) val colsSql = cols.joinToString(",") { "\"$it\"" } val pkOrder = if ("id" in cols) "id" else cols.joinToString(",") { "\"$it\"" } conn.createStatement().use { st -> - st.executeQuery("SELECT $colsSql FROM \"$table\" ORDER BY $pkOrder").use { rs -> - val meta = rs.metaData - val n = meta.columnCount + st.executeQuery("SELECT $colsSql FROM \"$t\" ORDER BY $pkOrder").use { rs -> + val n = rs.metaData.columnCount while (rs.next()) { - for (i in 1..n) encodeCell(md, rs, i) - md.update(byteArrayOf(0xFF.toByte())) + for (i in 1..n) encodeCell(sink, rs, i) + sink.update(byteArrayOf(0xFF.toByte())) } } } + tableHashes[t] = hex(table.digest()) // digest() also resets for the next table } - val digest = md.digest() - return digest.joinToString("") { "%02x".format(it) } + return Report(hex(whole.digest()), tableHashes) } - private fun encodeCell(md: MessageDigest, rs: java.sql.ResultSet, i: Int) { + /** Feeds the same bytes to the whole-DB digest and the current table's digest. */ + private class DualDigest(private val whole: MessageDigest, private val table: MessageDigest) { + fun update(bytes: ByteArray) { + whole.update(bytes) + table.update(bytes) + } + } + + private fun hex(digest: ByteArray): String = digest.joinToString("") { "%02x".format(it) } + + private fun encodeCell(sink: DualDigest, rs: java.sql.ResultSet, i: Int) { val obj = rs.getObject(i) when { - obj == null || rs.wasNull() -> md.update(byteArrayOf(0)) - obj is ByteArray -> { md.update(byteArrayOf(1)); md.update(obj) } - obj is Number -> { md.update(byteArrayOf(2)); md.update(obj.toString().toByteArray()) } - else -> { md.update(byteArrayOf(3)); md.update(obj.toString().toByteArray()) } + obj == null || rs.wasNull() -> sink.update(byteArrayOf(0)) + obj is ByteArray -> { sink.update(byteArrayOf(1)); sink.update(obj) } + obj is Number -> { sink.update(byteArrayOf(2)); sink.update(obj.toString().toByteArray()) } + else -> { sink.update(byteArrayOf(3)); sink.update(obj.toString().toByteArray()) } } - md.update(byteArrayOf(0x1F)) // unit separator between cells + sink.update(byteArrayOf(0x1F)) // unit separator between cells } private fun readColumnsCanonical(conn: Connection, table: String): List? { diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchPipelineCli.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchPipelineCli.kt index d1fcdbed..24f13a4a 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchPipelineCli.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchPipelineCli.kt @@ -97,9 +97,10 @@ fun main(args: Array) { // Verify apply: copy prev, apply patch, hash, compare with hash(new). val target = outPath.resolveSibling("verify-${outPath.fileName}") Files.copy(prevPath, target, StandardCopyOption.REPLACE_EXISTING) - val newHash = DriverManager.getConnection("jdbc:sqlite:${newPath.toAbsolutePath()}").use { - LogicalContentHasher.forSchemaVersion(toSchemaVersion).compute(it) + val newReport = DriverManager.getConnection("jdbc:sqlite:${newPath.toAbsolutePath()}").use { + LogicalContentHasher.forSchemaVersion(toSchemaVersion).computeReport(it) } + val newHash = newReport.wholeHash DriverManager.getConnection("jdbc:sqlite:${target.toAbsolutePath()}").use { conn -> conn.createStatement().use { it.execute("PRAGMA foreign_keys = ON") } // The producer ships upserts/deletes for every table in @@ -114,11 +115,21 @@ fun main(args: Array) { expectedToContentHash = newHash, expectedToSchemaVersion = toSchemaVersion, ) - val appliedHash = LogicalContentHasher.forSchemaVersion(toSchemaVersion).compute(conn) + val appliedReport = LogicalContentHasher.forSchemaVersion(toSchemaVersion).computeReport(conn) + val appliedHash = appliedReport.wholeHash check(appliedHash == newHash) { "Patch verification FAILED: applied=$appliedHash expected=$newHash — " + "inspect with diagnoseHashMismatch; refusing to publish this patch." } + // The per-table hashes ship in the manifest and drive the client's + // partial verification, so they are gated as hard as the whole hash. + val divergent = newReport.tableHashes.keys.filter { + appliedReport.tableHashes[it] != newReport.tableHashes[it] + } + check(divergent.isEmpty()) { + "Patch verification FAILED: per-table hashes diverge for ${divergent.joinToString(", ")} — " + + "refusing to publish this patch." + } logger.i { "✅ Patch apply verified: target hash matches new ($newHash)" } } runCatching { Files.deleteIfExists(target) } @@ -158,6 +169,10 @@ fun main(args: Array) { "(${"%.1f".format(compressed.compressedSize * 100.0 / Files.size(outPath))}%)" } + val prevReport = DriverManager.getConnection("jdbc:sqlite:${prevPath.toAbsolutePath()}").use { + LogicalContentHasher.forSchemaVersion(fromSchemaVersion).computeReport(it) + } + // Emit a per-delta manifest.json next to the .zst. ReleaseManifestWriter(logger).writeManifest( patchFile = outPath, @@ -165,10 +180,10 @@ fun main(args: Array) { toVersion = to, fromSchemaVersion = fromSchemaVersion, toSchemaVersion = toSchemaVersion, - fromContentHash = DriverManager.getConnection("jdbc:sqlite:${prevPath.toAbsolutePath()}").use { - LogicalContentHasher.forSchemaVersion(fromSchemaVersion).compute(it) - }, + fromContentHash = prevReport.wholeHash, toContentHash = newHash, + fromTableContentHashes = prevReport.tableHashes, + toTableContentHashes = newReport.tableHashes, compressed = ReleaseManifestWriter.CompressedPatchSpec( file = compressed.compressedFile, sha256 = compressed.compressedSha256, diff --git a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/ReleaseManifestWriter.kt b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/ReleaseManifestWriter.kt index 681ccb73..006fbf70 100644 --- a/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/ReleaseManifestWriter.kt +++ b/generator/common/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/ReleaseManifestWriter.kt @@ -51,8 +51,15 @@ class ReleaseManifestWriter( toContentHash: String, compressed: CompressedPatchSpec, catalogBlobName: String? = null, + fromTableContentHashes: Map = emptyMap(), + toTableContentHashes: Map = emptyMap(), ): Path { require(Files.isRegularFile(patchFile)) { "patch file not found: $patchFile" } + // Both maps or neither: a client that sees only one side cannot decide + // which tables a patch left untouched, and would silently fall back. + require(fromTableContentHashes.isEmpty() == toTableContentHashes.isEmpty()) { + "fromTableContentHashes and toTableContentHashes must both be given or both omitted" + } val uncompressedSha256 = sha256(patchFile) val uncompressedSize = Files.size(patchFile) val target = compressed.file.resolveSibling("${compressed.file.fileName}.manifest.json") @@ -65,6 +72,10 @@ class ReleaseManifestWriter( append(" \"patchFormatVersion\": ").append(PatchDbSchema.CURRENT_VERSION).append(",\n") append(" \"fromContentHash\": ").appendString(fromContentHash).append(",\n") append(" \"toContentHash\": ").appendString(toContentHash).append(",\n") + if (fromTableContentHashes.isNotEmpty()) { + appendHashMap("fromTableContentHashes", fromTableContentHashes) + appendHashMap("toTableContentHashes", toTableContentHashes) + } append(" \"patchFiles\": [\n") append(" {\n") append(" \"file\": ").appendString(compressed.file.fileName.toString()).append(",\n") @@ -166,6 +177,18 @@ class ReleaseManifestWriter( // ─── Minimal JSON helpers (kept dep-free) ────────────────────────────────── + /** Emits `"": { … },` — keys in map iteration order (= hash table order). */ + private fun StringBuilder.appendHashMap(name: String, values: Map) { + append(" ").appendString(name).append(": {\n") + var i = 0 + for ((table, hash) in values) { + append(" ").appendString(table).append(": ").appendString(hash) + if (++i < values.size) append(",") + append("\n") + } + append(" },\n") + } + private fun StringBuilder.appendString(s: String): StringBuilder { append('"') for (c in s) when (c) { diff --git a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalHashContractTest.kt b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalHashContractTest.kt new file mode 100644 index 00000000..714b3bb9 --- /dev/null +++ b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/LogicalHashContractTest.kt @@ -0,0 +1,91 @@ +package io.github.kdroidfilter.seforimlibrary.common.patch + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.sql.Connection +import java.sql.DriverManager +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Shared oracle with the Dart client (`contract.yml` cmp's the two fixtures). + * A failure here means the two hashers drifted and deltas would be rejected. + */ +class LogicalHashContractTest { + + private val fixture = Json.parseToJsonElement( + requireNotNull(javaClass.getResourceAsStream("/logical_hash_contract.json")) { + "logical_hash_contract.json missing from test resources" + }.bufferedReader(Charsets.UTF_8).readText(), + ).jsonObject + + private val schemaVersion = fixture.getValue("schemaVersion").jsonPrimitive.int + private val hashTableOrder = + fixture.getValue("hashTableOrder").jsonArray.map { it.jsonPrimitive.content } + private val setupSql = fixture.getValue("setupSql").jsonArray.map { it.jsonPrimitive.content } + private val wholeHash = fixture.getValue("wholeHash").jsonPrimitive.content + private val tableHashes = fixture.getValue("tableHashes").jsonObject + .mapValues { it.value.jsonPrimitive.content } + + private fun withFixtureDb(block: (Connection) -> T): T = + DriverManager.getConnection("jdbc:sqlite::memory:").use { conn -> + conn.createStatement().use { st -> setupSql.forEach { st.execute(it) } } + block(conn) + } + + @Test + fun `hash table order matches the fixture`() { + assertEquals(hashTableOrder, LogicalContentHasher.tablesForSchemaVersion(schemaVersion)) + } + + @Test + fun `whole hash and every per-table hash match the shared oracle`() { + val report = withFixtureDb { + LogicalContentHasher.forSchemaVersion(schemaVersion).computeReport(it) + } + assertEquals(wholeHash, report.wholeHash, "whole hash") + assertEquals(hashTableOrder, report.tableHashes.keys.toList(), "per-table key order") + for (table in hashTableOrder) { + assertEquals(tableHashes[table], report.tableHashes[table], "table hash for '$table'") + } + } + + @Test + fun `compute is unchanged by the report API`() { + val (whole, report) = withFixtureDb { + val hasher = LogicalContentHasher.forSchemaVersion(schemaVersion) + hasher.compute(it) to hasher.computeReport(it) + } + assertEquals(whole, report.wholeHash) + assertEquals(wholeHash, whole) + } + + @Test + fun `absent tables still contribute a hash and distinct tables differ`() { + val report = withFixtureDb { + LogicalContentHasher.forSchemaVersion(schemaVersion).computeReport(it) + } + // `topic` is absent from the fixture DB — it hashes its prefix only, + // which must still differ from another absent table's prefix hash. + assertTrue(report.tableHashes.getValue("topic").isNotEmpty()) + assertTrue(report.tableHashes.getValue("topic") != report.tableHashes.getValue("pub_place")) + } + + @Test + fun `a table's hash is the digest of its stream alone`() { + // A one-table hasher's whole hash must equal that table's entry in the + // full run: wholeHash is the digest of the concatenated table streams. + withFixtureDb { conn -> + val full = LogicalContentHasher.forSchemaVersion(schemaVersion).computeReport(conn) + for (table in hashTableOrder) { + val single = LogicalContentHasher(listOf(table)).computeReport(conn) + assertEquals(full.tableHashes[table], single.wholeHash, "stream digest for '$table'") + assertEquals(single.wholeHash, single.tableHashes.getValue(table)) + } + } + } +} diff --git a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/ReleaseManifestWriterTest.kt b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/ReleaseManifestWriterTest.kt index 43625fdb..45b706d6 100644 --- a/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/ReleaseManifestWriterTest.kt +++ b/generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/ReleaseManifestWriterTest.kt @@ -5,6 +5,7 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import java.nio.file.Files import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -74,6 +75,85 @@ class ReleaseManifestWriterTest { assertFalse(body.contains("catalogBlobName"), body) } + @Test + fun `writeManifest emits both table-hash maps in iteration order`() { + val patch = tmp.newFile("patch.db").toPath() + Files.writeString(patch, "hello world") + val compressed = PatchCompressor.compress(patch, level = 3, workers = 1) + val target = ReleaseManifestWriter().writeManifest( + patchFile = patch, + fromVersion = 1, + toVersion = 2, + fromSchemaVersion = 5, + toSchemaVersion = 5, + fromContentHash = "aaaa", + toContentHash = "bbbb", + compressed = ReleaseManifestWriter.CompressedPatchSpec( + file = compressed.compressedFile, + sha256 = compressed.compressedSha256, + size = compressed.compressedSize, + compression = "zstd", + ), + fromTableContentHashes = linkedMapOf("source" to "11", "author" to "22"), + toTableContentHashes = linkedMapOf("source" to "11", "author" to "33"), + ) + val body = Files.readString(target) + assertTrue( + body.contains("\"fromTableContentHashes\": {\n \"source\": \"11\",\n \"author\": \"22\"\n },"), + body, + ) + assertTrue( + body.contains("\"toTableContentHashes\": {\n \"source\": \"11\",\n \"author\": \"33\"\n },"), + body, + ) + // Still a valid JSON object with the required whole-DB hashes. + assertTrue(body.contains("\"toContentHash\": \"bbbb\"")) + assertTrue(body.contains("\"patchFiles\": [")) + } + + @Test + fun `writeManifest omits the table-hash maps when not supplied`() { + val patch = tmp.newFile("patch.db").toPath() + Files.writeString(patch, "hello world") + val compressed = PatchCompressor.compress(patch, level = 3, workers = 1) + val target = ReleaseManifestWriter().writeManifest( + patchFile = patch, + fromVersion = 1, toVersion = 2, + fromSchemaVersion = 5, toSchemaVersion = 5, + fromContentHash = "aaaa", toContentHash = "bbbb", + compressed = ReleaseManifestWriter.CompressedPatchSpec( + file = compressed.compressedFile, + sha256 = compressed.compressedSha256, + size = compressed.compressedSize, + compression = "zstd", + ), + ) + val body = Files.readString(target) + assertFalse(body.contains("TableContentHashes"), body) + } + + @Test + fun `writeManifest rejects only one of the two table-hash maps`() { + val patch = tmp.newFile("patch.db").toPath() + Files.writeString(patch, "hello world") + val compressed = PatchCompressor.compress(patch, level = 3, workers = 1) + assertFailsWith { + ReleaseManifestWriter().writeManifest( + patchFile = patch, + fromVersion = 1, toVersion = 2, + fromSchemaVersion = 5, toSchemaVersion = 5, + fromContentHash = "aaaa", toContentHash = "bbbb", + compressed = ReleaseManifestWriter.CompressedPatchSpec( + file = compressed.compressedFile, + sha256 = compressed.compressedSha256, + size = compressed.compressedSize, + compression = "zstd", + ), + toTableContentHashes = mapOf("source" to "11"), + ) + } + } + @Test fun `upsertReleaseMeta creates and merges incrementally`() { val metaPath = tmp.newFolder().toPath().resolve("release_meta.json") diff --git a/generator/common/src/jvmTest/resources/logical_hash_contract.json b/generator/common/src/jvmTest/resources/logical_hash_contract.json new file mode 100644 index 00000000..83be4c9c --- /dev/null +++ b/generator/common/src/jvmTest/resources/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" + } +}