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
34 changes: 29 additions & 5 deletions Packages/WhoopStore/Sources/WhoopStore/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -980,7 +980,19 @@ extension WhoopStore {
//
// Additive only: a NEW table, no existing row touched, old readers unaffected.
migrator.registerMigration("v43-body-weight") { db in
try db.create(table: "bodyWeightEntry") { t in
// Compatibility for local/internal builds made from the pre-merge feature snapshot. That
// snapshot briefly shipped this exact schema under `v42-body-weight`; upstream then occupied
// v42, so the final migration had to move to v43. Such a database has the table but not this
// marker, and an unconditional CREATE traps every subsequent store open in the same failure.
// Keep the leniency marker-gated: an unrelated database that happens to contain a table with
// this name must still fail loudly rather than being mistaken for our schema.
let hasPrototypeMigration = try Bool.fetchOne(db, sql: """
SELECT EXISTS(
SELECT 1 FROM grdb_migrations WHERE identifier = 'v42-body-weight'
)
""") ?? false
try db.create(table: "bodyWeightEntry",
options: hasPrototypeMigration ? [.ifNotExists] : []) { t in
t.column("id", .text).primaryKey()
t.column("deviceId", .text).notNull()
t.column("day", .text).notNull() // yyyy-MM-dd (projection key)
Expand All @@ -994,19 +1006,31 @@ extension WhoopStore {
try db.create(index: "idx_bodyWeightEntry_natural",
on: "bodyWeightEntry",
columns: ["deviceId", "takenAt", "source"],
unique: true)
options: hasPrototypeMigration ? [.unique, .ifNotExists] : [.unique])
// History reads scan (deviceId) then walk takenAt in order.
try db.create(index: "idx_bodyWeightEntry_device_takenAt",
on: "bodyWeightEntry", columns: ["deviceId", "takenAt"])
on: "bodyWeightEntry", columns: ["deviceId", "takenAt"],
options: hasPrototypeMigration ? [.ifNotExists] : [])
}
// v44-energy-coverage: the on-device day-calorie estimate is a sum over observed HR seconds.
// Persist that denominator beside the value so consumers can distinguish a fully-covered day
// from a short, intense window without trying to reverse-engineer wear time from calories/BMR.
// Nullable keeps every imported and pre-v44 row honestly "unknown" and avoids a costly history
// scan during launch migration; normal day re-analysis fills recent/current rows organically.
migrator.registerMigration("v44-energy-coverage") { db in
try db.alter(table: "dailyMetric") { t in
t.add(column: "energyCoverageSeconds", .integer)
// The same pre-merge snapshot called this `v43-energy-coverage`. Only trust an already-present
// column when that exact prototype marker exists; otherwise a duplicate remains a real schema
// error. GRDB/SQLite has no portable ADD COLUMN IF NOT EXISTS for this deployment range.
let hasPrototypeMigration = try Bool.fetchOne(db, sql: """
SELECT EXISTS(
SELECT 1 FROM grdb_migrations WHERE identifier = 'v43-energy-coverage'
)
""") ?? false
let hasColumn = try db.columns(in: "dailyMetric").contains { $0.name == "energyCoverageSeconds" }
if !hasPrototypeMigration || !hasColumn {
try db.alter(table: "dailyMetric") { t in
t.add(column: "energyCoverageSeconds", .integer)
}
}
}
return migrator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,69 @@ final class BodyWeightStoreTests: XCTestCase {
}
}

/// Internal builds briefly used v42 for weight and v43 for energy coverage before upstream took
/// v42 and forced both identifiers forward by one. Opening one of those databases with the final
/// migrator must adopt the identical schema, preserve its rows, and record the released markers.
func testPrototypeMigrationIdentifiersUpgradeWithoutDataLoss() throws {
let dbQueue = try DatabaseQueue()
let migrator = WhoopStore.makeMigrator()
try migrator.migrate(dbQueue, upTo: "v42-ppg-burst-index")

try dbQueue.write { db in
try db.create(table: "bodyWeightEntry") { t in
t.column("id", .text).primaryKey()
t.column("deviceId", .text).notNull()
t.column("day", .text).notNull()
t.column("takenAt", .integer).notNull()
t.column("weightKg", .double).notNull()
t.column("source", .text).notNull()
t.column("note", .text)
}
try db.create(index: "idx_bodyWeightEntry_natural", on: "bodyWeightEntry",
columns: ["deviceId", "takenAt", "source"], unique: true)
try db.create(index: "idx_bodyWeightEntry_device_takenAt", on: "bodyWeightEntry",
columns: ["deviceId", "takenAt"])
try db.alter(table: "dailyMetric") { t in
t.add(column: "energyCoverageSeconds", .integer)
}
try db.execute(sql: """
INSERT INTO grdb_migrations (identifier)
VALUES ('v42-body-weight'), ('v43-energy-coverage')
""")
try db.execute(sql: """
INSERT INTO bodyWeightEntry
(id, deviceId, day, takenAt, weightKg, source, note)
VALUES ('legacy-weight', 'noop-weight', '2026-08-24', 1770000000, 81.7, 'manual', 'keep me')
""")
try db.execute(sql: """
INSERT INTO dailyMetric (deviceId, day, energyCoverageSeconds)
VALUES ('my-whoop', '2026-08-24', 43200)
""")
}

XCTAssertNoThrow(try migrator.migrate(dbQueue))

try dbQueue.read { db in
XCTAssertEqual(try Double.fetchOne(db, sql: """
SELECT weightKg FROM bodyWeightEntry WHERE id = 'legacy-weight'
"""), 81.7)
XCTAssertEqual(try String.fetchOne(db, sql: """
SELECT note FROM bodyWeightEntry WHERE id = 'legacy-weight'
"""), "keep me")
XCTAssertEqual(try Int.fetchOne(db, sql: """
SELECT energyCoverageSeconds FROM dailyMetric
WHERE deviceId = 'my-whoop' AND day = '2026-08-24'
"""), 43_200)
let applied = try Set(String.fetchAll(db, sql: """
SELECT identifier FROM grdb_migrations
WHERE identifier IN ('v42-body-weight', 'v43-body-weight',
'v43-energy-coverage', 'v44-energy-coverage')
"""))
XCTAssertEqual(applied, ["v42-body-weight", "v43-body-weight",
"v43-energy-coverage", "v44-energy-coverage"])
}
}

// MARK: - Write + read back

func testUpsertStoresAndReadsBack() async throws {
Expand Down
Loading