Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.",
"_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here. The optional `grdbMigrationLineages` is the same idea for migration IDENTIFIERS: a fork or long-lived branch that numbered its own migrations before upstream reached those numbers declares them there instead of renumbering \u2014 GRDB keys migrations by identifier string, so renaming one wedges a database that already ran it. Whatever is not declared is the baseline lineage and is still checked as exactly v1..vN.",
"roomVersion": 25,
"grdbMigrations": [
"v1",
Expand Down
64 changes: 54 additions & 10 deletions Packages/WhoopStore/Tests/WhoopStoreTests/SchemaOracleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ final class SchemaOracleTests: XCTestCase {
let grdbMigrations: [String]
let divergenceReasons: [String: String]
let tables: [String: OracleTable]
/// Absent upstream, where there is only the baseline lineage. See `MigrationLineage`.
let grdbMigrationLineages: [String: MigrationLineage]?
var lineages: [String: MigrationLineage] { grdbMigrationLineages ?? [:] }
}
/// A set of migration identifiers numbered independently of the baseline lineage — a fork that
/// reached `vN` before upstream did. Declaring one is what lets the two coexist without a renumber.
struct MigrationLineage: Decodable {
let reason: String
let migrations: [String]
}
struct OracleTable: Decodable {
let platform: String // "both" | "ios_only" | "android_only"
Expand Down Expand Up @@ -201,23 +210,58 @@ final class SchemaOracleTests: XCTestCase {
/// entirely, because the first already recorded that name in `grdb_migrations`.
///
/// So: identifiers unique, and their `vN` prefixes exactly 1...N with no gaps and no repeats.
///
/// The one sanctioned exception is a DECLARED lineage. A fork that numbered its own migrations
/// before upstream reached those numbers cannot renumber its way out — GRDB keys by identifier
/// string, so renaming one makes it read as un-applied on a device that already ran it and the
/// migrator re-runs its body against a schema that already has the table. Such a fork lists its
/// identifiers under `grdbMigrationLineages`; everything NOT listed is the baseline lineage and is
/// still held to exactly v1...vN. So the escape hatch costs a written-down reason and cannot be
/// taken by accident — an undeclared collision still fails, which is what catches #369 vs #475.
func testGrdbMigrationIdentifiersAreUniqueAndSequential() throws {
let oracle = try loadOracle()
let ids = WhoopStore.makeMigrator().migrations
XCTAssertEqual(Set(ids).count, ids.count,
"duplicate GRDB migration identifier — GRDB would silently SKIP the second body")

var numbers: [Int] = []
for id in ids {
let digits = id.dropFirst().prefix { $0.isNumber }
guard id.hasPrefix("v"), let n = Int(digits) else {
return XCTFail("migration identifier '\(id)' is not of the form v<N>[-slug]")
func version(of id: String) -> Int? {
guard id.hasPrefix("v"), let n = Int(id.dropFirst().prefix(while: \.isNumber)) else { return nil }
return n
}
for id in ids where version(of: id) == nil {
return XCTFail("migration identifier '\(id)' is not of the form v<N>[-slug]")
}

// A declared lineage must be true (every member registered) and self-consistent (its own vN
// strictly increasing), so the ledger can only shrink deliberately — same rule the divergence
// reasons live by.
var claimedBy: [String: String] = [:]
for (name, lineage) in oracle.lineages.sorted(by: { $0.key < $1.key }) {
XCTAssertFalse(lineage.migrations.isEmpty, "lineage '\(name)' declares no migrations — delete it")
for id in lineage.migrations {
if let other = claimedBy.updateValue(name, forKey: id) {
return XCTFail("'\(id)' is claimed by both lineage '\(other)' and '\(name)'")
}
guard ids.contains(id) else {
return XCTFail("lineage '\(name)' declares '\(id)', which is not registered — a lineage "
+ "entry that stopped being true must be deleted, not left to rot")
}
}
let numbers = lineage.migrations.compactMap(version(of:))
for (a, b) in zip(numbers, numbers.dropFirst()) where b <= a {
return XCTFail("lineage '\(name)' goes v\(a) then v\(b) — a lineage numbers its own "
+ "migrations, so its vN must strictly increase")
}
numbers.append(n)
}
for (offset, n) in numbers.enumerated() where n != offset + 1 {
return XCTFail("GRDB migration '\(ids[offset])' claims v\(n) but is #\(offset + 1) in "
+ "registration order — two migrations claiming the same vN, or a gap, makes the "
+ "GRDB-name <-> Room-version mapping ambiguous. Renumber before merging.")

let baseline = ids.filter { claimedBy[$0] == nil }
for (offset, id) in baseline.enumerated() where version(of: id) != offset + 1 {
return XCTFail("GRDB migration '\(id)' claims v\(version(of: id)!) but is #\(offset + 1) in the "
+ "BASELINE lineage (registration order minus every lineage schema_oracle.json "
+ "declares) — two migrations claiming the same vN, or a gap, makes the "
+ "GRDB-name <-> Room-version mapping ambiguous. Renumber before merging, or, if "
+ "the identifier already shipped and renaming it would wedge a live database, "
+ "declare it in grdbMigrationLineages with a reason.")
}
}

Expand Down
52 changes: 47 additions & 5 deletions android/app/src/test/java/com/noop/data/SchemaOracleTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -238,25 +238,67 @@ class SchemaOracleTest {
* migrations by NAME and applies them in registration order; Room keys by integer version, so the
* only thing that can be compared mechanically is the RESULTING schema — which is what the tests
* above do. Pinning the two identifier spaces is what forces them to be re-checked together.
*
* The one sanctioned exception is a DECLARED lineage. A fork that numbered its own migrations before
* upstream reached those numbers cannot renumber its way out — GRDB keys by identifier string, so
* renaming one makes it read as un-applied on a device that already ran it. Such a fork lists its
* identifiers under `grdbMigrationLineages`; everything NOT listed is the baseline lineage and is
* still held to exactly v1..vN. An UNDECLARED collision still fails, which is the #369-vs-#475 case.
* Mirrors `testGrdbMigrationIdentifiersAreUniqueAndSequential` in the Swift half.
*/
@Test
fun pinnedMigrationIdentifiersAreCoherent() {
val oracle = loadOracle()
val grdb = oracle.getJSONArray("grdbMigrations").strings()
assertEquals("duplicate GRDB migration identifier in schema_oracle.json", grdb.size, grdb.toSet().size)
grdb.forEachIndexed { i, id ->
val n = id.removePrefix("v").takeWhile { it.isDigit() }.toIntOrNull()

// A declared lineage must be true (every member pinned) and self-consistent (its own vN strictly
// increasing), so the ledger can only shrink deliberately — same rule the divergence reasons live by.
val lineages = oracle.optJSONObject("grdbMigrationLineages") ?: JSONObject()
val claimedBy = HashMap<String, String>()
for (name in lineages.keys().asSequence().sorted()) {
val migrations = lineages.getJSONObject(name).getJSONArray("migrations").strings()
assertTrue("lineage '$name' declares no migrations — delete it", migrations.isNotEmpty())
val numbers = migrations.map { id ->
val other = claimedBy.put(id, name)
assertTrue("'$id' is claimed by both lineage '$other' and '$name'", other == null)
assertTrue(
"lineage '$name' declares '$id', which is not in grdbMigrations — a lineage entry that " +
"stopped being true must be deleted, not left to rot",
grdb.contains(id),
)
val n = migrationVersion(id)
assertNotNull("lineage '$name' member '$id' is not of the form v<N>[-slug]", n)
n!!
}
numbers.zipWithNext { a, b ->
assertTrue(
"lineage '$name' goes v$a then v$b — a lineage numbers its own migrations, so its vN " +
"must strictly increase",
b > a,
)
}
}

grdb.filterNot { claimedBy.containsKey(it) }.forEachIndexed { i, id ->
assertEquals(
"GRDB migration '$id' claims v$n but is #${i + 1} in registration order — two migrations " +
"claiming the same vN, or a gap, makes the GRDB-name <-> Room-version mapping ambiguous.",
"GRDB migration '$id' claims v${migrationVersion(id)} but is #${i + 1} in the BASELINE " +
"lineage (grdbMigrations minus every lineage grdbMigrationLineages declares) — two " +
"migrations claiming the same vN, or a gap, makes the GRDB-name <-> Room-version " +
"mapping ambiguous. Renumber before merging, or, if the identifier already shipped and " +
"renaming it would wedge a live database, declare it in grdbMigrationLineages.",
i + 1,
n,
migrationVersion(id),
)
}
// loadRoomSchema asserts the exported version equals this; call it so the check is not vacuous.
loadRoomSchema(oracle.getInt("roomVersion"))
}

/** The `N` of a `v<N>[-slug]` migration identifier, or null if it is not of that form. */
private fun migrationVersion(id: String): Int? =
id.removePrefix("v").takeWhile { it.isDigit() }.toIntOrNull()

/**
* The Android and Swift copies of the oracle MUST be byte-identical, so neither platform can edit its
* fixture without the other. Skips gracefully if the Swift tree isn't present.
Expand Down
2 changes: 1 addition & 1 deletion android/app/src/test/resources/schema_oracle.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.",
"_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here. The optional `grdbMigrationLineages` is the same idea for migration IDENTIFIERS: a fork or long-lived branch that numbered its own migrations before upstream reached those numbers declares them there instead of renumbering \u2014 GRDB keys migrations by identifier string, so renaming one wedges a database that already ran it. Whatever is not declared is the baseline lineage and is still checked as exactly v1..vN.",
"roomVersion": 25,
"grdbMigrations": [
"v1",
Expand Down