diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift index a62c089eaf..78d7d8299c 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift @@ -1096,6 +1096,7 @@ extension WhoopStore { try db.create(index: "idx_liftSet_session_ord", on: "liftSet", columns: ["sessionId", "ord"], options: [.ifNotExists]) } + // v47 (#2371): mark the WHOOP 5 500 ms fill beats already stored, with the rule `insert` applies to // every new batch (`WhoopStore.whoop5RrFillFlagSQL`). Data only, no schema change, and a MARK in // the v35 form, never a delete: the rows stay on disk. Twin of Room @@ -1103,6 +1104,26 @@ extension WhoopStore { migrator.registerMigration("v47-rr-whoop5-fill") { db in try db.execute(sql: WhoopStore.whoop5RrFillMigrationSQL) } + // v48 (#1410 tier 3): record which exact app build last computed each persisted score cell and + // when. This is separate from scoreInputProvenance: that table answers which INPUT/provider or + // estimator produced a value, while this one answers which CODE produced it. Existing history + // remains absent/unknown because its computing build and time cannot be reconstructed honestly. + // `scope` identifies the transaction that owns stale-row cleanup: a normal daily-score window may + // replace its own stamps without erasing independently-written weekly Fitness/Vitality/VO₂max or + // daily steps-estimate stamps. Additive only; no existing row is touched. + migrator.registerMigration("v48-score-computation-provenance") { db in + try db.create(table: "scoreComputationProvenance") { t in + t.column("deviceId", .text).notNull() + t.column("day", .text).notNull() + t.column("key", .text).notNull() + t.column("computedBy", .text).notNull() + t.column("computedAt", .integer).notNull() // unix milliseconds + t.column("scope", .text).notNull() + t.primaryKey(["deviceId", "day", "key"]) + } + try db.create(index: "idx_scoreComputationProvenance_computedBy", + on: "scoreComputationProvenance", columns: ["computedBy"]) + } return migrator } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift index 63996012c3..479c7f3f1e 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift @@ -135,7 +135,7 @@ public struct DeviceRegistryStore: Sendable { "hrSample", "rrInterval", "spo2Sample", "skinTempSample", "respSample", "gravitySample", "stepSample", "ppgHrSample", "event", "battery", "dailyMetric", "sleepSession", "journal", "workout", "appleDaily", "metricSeries", "dayOwnership", - "scoreInputProvenance", + "scoreInputProvenance", "scoreComputationProvenance", // Added: device-keyed tables introduced by later migrations that the list previously missed, so a // "delete all of this device's data" left raw captures (rawBatch), user-entered lab/blood markers // (labMarker), banked band sleep-state (sleepStateSample) and live coaching sessions diff --git a/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift b/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift index ce0431767c..e68426a778 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift @@ -32,15 +32,42 @@ public enum Vo2MaxEstimator: String, Codable, Sendable { public static func forWaistCm(_ waistCm: Double) -> Self { waistCm > 0 ? .nes : .uth } } +/// Which persistence transaction owns a computation stamp. The daily window replaces only its own rows, +/// so independently-written weekly/standalone metric-series provenance survives an ordinary re-score. +public enum ScoreComputationScope: String, Codable, Sendable { + case scoreWindow = "score-window" + case metricSeries = "metric-series" +} + +/// Exact app identity and wall-clock instant captured for one computation pass. `computedBy` deliberately +/// includes platform, marketing version, and build number because a cross-platform backup may contain rows +/// produced by either app, and two staging builds may share a marketing version. +public struct ScoreComputationStamp: Equatable, Codable, Sendable { + public let computedBy: String + public let computedAt: Int64 // unix milliseconds + + public init(computedBy: String, computedAt: Int64) { + self.computedBy = computedBy + self.computedAt = computedAt + } + + // Kotlin twin: `ScoreComputationStamp.buildIdentity`. + public static func buildIdentity(platform: String, appVersion: String, appBuild: String) -> String { + "\(platform):\(appVersion)+\(appBuild)" + } +} + extension WhoopStore { /// Persist computed daily/series scores and their input provenance in one SQLite transaction. /// Replacing daily-score provenance in the scoring window prevents stale attribution when a metric /// disappears or changes provider; independently-owned weekly VO₂max method tags survive. Any write /// failure rolls back both scores and metadata. + // Kotlin twin: `WhoopDao.replaceComputedScoreWindow`. public func persistComputedScores( dailyMetrics: [DailyMetric], metricPoints: [MetricPoint], provenance: [ScoreInputProvenanceRow], + computation: ScoreComputationStamp, deviceId: String, from: String, to: String, @@ -74,6 +101,10 @@ extension WhoopStore { DELETE FROM scoreInputProvenance WHERE deviceId = ? AND day >= ? AND day <= ? AND key != 'vo2max_est' """, arguments: [deviceId, from, to]) + try db.execute(sql: """ + DELETE FROM scoreComputationProvenance + WHERE deviceId = ? AND day >= ? AND day <= ? AND scope = ? + """, arguments: [deviceId, from, to, ScoreComputationScope.scoreWindow.rawValue]) for row in provenance { try db.execute(sql: """ INSERT INTO scoreInputProvenance (deviceId, day, key, sourceId) @@ -81,14 +112,25 @@ extension WhoopStore { ON CONFLICT(deviceId, day,key) DO UPDATE SET sourceId = excluded.sourceId """, arguments: [deviceId, row.day, row.key, row.sourceId]) } + let cells = Set(provenance.map { "\($0.day)\u{1F}\($0.key)" }) + .union(metricPoints.map { "\($0.day)\u{1F}\($0.key)" }) + for cell in cells.sorted() { + let parts = cell.split(separator: "\u{1F}", maxSplits: 1).map(String.init) + guard parts.count == 2 else { continue } + try Self.upsertComputationProvenance( + day: parts[0], key: parts[1], stamp: computation, scope: .scoreWindow, + deviceId: deviceId, in: db) + } } } /// Persist a metric-series batch and its specialized provenance in one SQLite transaction. Used by /// weekly VO₂max so a method label can never describe an older/newer value after a partial write. + // Kotlin twin: `WhoopDao.upsertMetricSeriesWithProvenance`. public func persistMetricSeriesWithProvenance( points: [MetricPoint], provenance: [ScoreInputProvenanceRow], + computation: ScoreComputationStamp, deviceId: String ) async throws { try syncWrite { db in @@ -100,9 +142,30 @@ extension WhoopStore { ON CONFLICT(deviceId, day,key) DO UPDATE SET sourceId = excluded.sourceId """, arguments: [deviceId, row.day, row.key, row.sourceId]) } + for point in points { + try Self.upsertComputationProvenance( + day: point.day, key: point.key, stamp: computation, scope: .metricSeries, + deviceId: deviceId, in: db) + } } } + // Kotlin twin: `WhoopDao.upsertScoreComputationProvenance`. + private static func upsertComputationProvenance( + day: String, key: String, stamp: ScoreComputationStamp, scope: ScoreComputationScope, + deviceId: String, in db: Database + ) throws { + try db.execute(sql: """ + INSERT INTO scoreComputationProvenance + (deviceId, day, key, computedBy, computedAt, scope) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(deviceId, day, key) DO UPDATE SET + computedBy = excluded.computedBy, + computedAt = excluded.computedAt, + scope = excluded.scope + """, arguments: [deviceId, day, key, stamp.computedBy, stamp.computedAt, scope.rawValue]) + } + /// Input source for one computed score. Missing means the score predates provenance storage or its /// attribution could not be persisted; callers must omit the badge instead of guessing. public func scoreInputSource(deviceId: String, day: String, key: String) async throws -> String? { diff --git a/Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift b/Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift index f75e185afa..d4628ca2e6 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift @@ -8,7 +8,7 @@ public enum WhoopStoreInfo { /// The store schema-version marker, bumped per migration. Surfaced in the backup manifest (#1410) so an /// export records the platform's schema version (a platform-scoped indicator — Android reports its Room /// version independently; the two numbering schemes are not expected to match). - public static let schemaVersion = 18 + public static let schemaVersion = 19 } /// Serializes `DatabasePool` creation + migration so two concurrent opens of the SAME file can never diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/LabMarkerStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/LabMarkerStoreTests.swift index de19d4a9ba..39fa541687 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/LabMarkerStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/LabMarkerStoreTests.swift @@ -41,8 +41,8 @@ final class LabMarkerStoreTests: XCTestCase { } } - func testSchemaVersionIs18() { - XCTAssertEqual(WhoopStoreInfo.schemaVersion, 18) + func testSchemaVersionIs19() { + XCTAssertEqual(WhoopStoreInfo.schemaVersion, 19) } // MARK: - helpers diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift index 3ac96a6763..b7731be9e3 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/MetricsCacheTests.swift @@ -16,7 +16,7 @@ final class MetricsCacheTests: XCTestCase { } func testSchemaVersionBumped() { - XCTAssertEqual(WhoopStoreInfo.schemaVersion, 18) + XCTAssertEqual(WhoopStoreInfo.schemaVersion, 19) } // MARK: - sleep sessions diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/MigrationTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/MigrationTests.swift index f342c60358..592d8bb919 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/MigrationTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/MigrationTests.swift @@ -192,7 +192,7 @@ final class MigrationTests: XCTestCase { let cols = try await store.columnNamesForTest(table: table) XCTAssertTrue(cols.contains("synced"), "\(table) missing synced column") } - XCTAssertEqual(WhoopStoreInfo.schemaVersion, 18) + XCTAssertEqual(WhoopStoreInfo.schemaVersion, 19) } /// v13 adds the `userEdited` flag to sleepSession (user-corrected wake times survive re-sync). diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json index 157c04f1f5..3ef7700073 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_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.", - "roomVersion": 41, + "roomVersion": 42, "grdbMigrations": [ "v1", "v2", @@ -48,7 +48,8 @@ "v44-ppg-waveform-base-code", "v45-rr-source-index", "v46-lift-log", - "v47-rr-whoop5-fill" + "v47-rr-whoop5-fill", + "v48-score-computation-provenance" ], "divergenceReasons": { "android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.", @@ -1878,6 +1879,61 @@ } ] }, + "scoreComputationProvenance": { + "platform": "both", + "columns": [ + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "day", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "key", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "computedBy", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "computedAt", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "scope", + "affinity": "TEXT", + "notNull": true, + "default": null + } + ], + "primaryKey": [ + "deviceId", + "day", + "key" + ], + "indices": [ + { + "name": "idx_scoreComputationProvenance_computedBy", + "unique": false, + "columns": [ + "computedBy" + ] + } + ] + }, "scoreInputProvenance": { "platform": "both", "columns": [ diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/ScaffoldTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/ScaffoldTests.swift index 7c35318bb3..bd3343ca27 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/ScaffoldTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/ScaffoldTests.swift @@ -11,6 +11,6 @@ final class ScaffoldTests: XCTestCase { } func testLibraryVersionMarkerPresent() { - XCTAssertEqual(WhoopStoreInfo.schemaVersion, 18) + XCTAssertEqual(WhoopStoreInfo.schemaVersion, 19) } } diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/ScoreInputProvenanceStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/ScoreInputProvenanceStoreTests.swift index 38499cac8a..0293b1ef35 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/ScoreInputProvenanceStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/ScoreInputProvenanceStoreTests.swift @@ -1,15 +1,24 @@ import XCTest +import GRDB @testable import WhoopStore final class ScoreInputProvenanceStoreTests: XCTestCase { + private let stamp = ScoreComputationStamp( + computedBy: "apple:10.5.0+221", computedAt: 1_724_460_000_000) + func testMigrationCreatesMetricLevelTableAndIndex() async throws { let store = try await WhoopStore.inMemory() let tables = try await store.tableNames() let primaryKey = try await store.primaryKeyColumns("scoreInputProvenance") let indexes = try await store.indexNamesForTest(table: "scoreInputProvenance") + let computationPrimaryKey = try await store.primaryKeyColumns("scoreComputationProvenance") + let computationIndexes = try await store.indexNamesForTest(table: "scoreComputationProvenance") XCTAssertTrue(tables.contains("scoreInputProvenance")) + XCTAssertTrue(tables.contains("scoreComputationProvenance")) XCTAssertEqual(primaryKey, ["deviceId", "day", "key"]) + XCTAssertEqual(computationPrimaryKey, ["deviceId", "day", "key"]) XCTAssertTrue(indexes.contains("idx_scoreInputProvenance_source")) + XCTAssertTrue(computationIndexes.contains("idx_scoreComputationProvenance_computedBy")) } func testComputedScoresAndProvenancePersistTogether() async throws { @@ -26,6 +35,7 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { dailyMetrics: [daily], metricPoints: [rest], provenance: provenance, + computation: stamp, deviceId: "my-whoop-noop", from: daily.day, to: daily.day @@ -37,8 +47,16 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { let source = try await store.scoreInputSource( deviceId: "my-whoop-noop", day: daily.day, key: "recovery" ) + let recoveryBuild = try await store.computationRowForTest( + deviceId: "my-whoop-noop", day: daily.day, key: "recovery") + let restBuild = try await store.computationRowForTest( + deviceId: "my-whoop-noop", day: daily.day, key: "sleep_performance") XCTAssertEqual(storedDaily.first?.recovery, 71) XCTAssertEqual(source, "polar-1") + XCTAssertEqual(recoveryBuild?.computedBy, stamp.computedBy) + XCTAssertEqual(recoveryBuild?.computedAt, stamp.computedAt) + XCTAssertEqual(recoveryBuild?.scope, .scoreWindow) + XCTAssertEqual(restBuild?.computedBy, stamp.computedBy) } func testReplacingWindowRemovesProvenanceForMissingMetric() async throws { @@ -51,6 +69,7 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { .init(day: day, key: "recovery", sourceId: "polar-1"), .init(day: day, key: "strain", sourceId: "polar-1"), ], + computation: stamp, deviceId: "my-whoop-noop", from: day, to: day @@ -59,6 +78,7 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { dailyMetrics: [makeDaily(day: day, recovery: 72, strain: nil)], metricPoints: [], provenance: [.init(day: day, key: "recovery", sourceId: "oura-api")], + computation: .init(computedBy: "apple:10.5.1+222", computedAt: stamp.computedAt + 1), deviceId: "my-whoop-noop", from: day, to: day @@ -70,8 +90,14 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { let strainSource = try await store.scoreInputSource( deviceId: "my-whoop-noop", day: day, key: "strain" ) + let recoveryBuild = try await store.computationRowForTest( + deviceId: "my-whoop-noop", day: day, key: "recovery") + let strainBuild = try await store.computationRowForTest( + deviceId: "my-whoop-noop", day: day, key: "strain") XCTAssertEqual(recoverySource, "oura-api") XCTAssertNil(strainSource) + XCTAssertEqual(recoveryBuild?.computedBy, "apple:10.5.1+222") + XCTAssertNil(strainBuild) } func testEmptyPassDoesNotWipeExistingWindow() async throws { @@ -85,6 +111,7 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { dailyMetrics: [makeDaily(day: day, recovery: 71, strain: 42)], metricPoints: [], provenance: [.init(day: day, key: "recovery", sourceId: "polar-1")], + computation: stamp, deviceId: "my-whoop-noop", from: day, to: day ) // An EMPTY pass over the same window must leave the stored row + provenance untouched. @@ -92,12 +119,16 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { dailyMetrics: [], metricPoints: [], provenance: [], + computation: .init(computedBy: "apple:bad+pass", computedAt: stamp.computedAt + 1), deviceId: "my-whoop-noop", from: day, to: day ) let storedDaily = try await store.dailyMetrics(deviceId: "my-whoop-noop", from: day, to: day) let source = try await store.scoreInputSource(deviceId: "my-whoop-noop", day: day, key: "recovery") + let build = try await store.computationRowForTest( + deviceId: "my-whoop-noop", day: day, key: "recovery") XCTAssertEqual(storedDaily.first?.recovery, 71) // window not wiped XCTAssertEqual(source, "polar-1") // provenance not wiped + XCTAssertEqual(build?.computedBy, stamp.computedBy) } func testVo2MaxValueAndEstimatorPersistTogetherAndSurviveDailyRescore() async throws { @@ -106,6 +137,7 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { try await store.persistMetricSeriesWithProvenance( points: [MetricPoint(day: day, key: "vo2max_est", value: 48)], provenance: [ScoreInputProvenanceRow(day: day, key: "vo2max_est", sourceId: "nes")], + computation: stamp, deviceId: "my-whoop-noop" ) @@ -114,6 +146,7 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { dailyMetrics: [makeDaily(day: day, recovery: 71, strain: 42)], metricPoints: [], provenance: [.init(day: day, key: "recovery", sourceId: "my-whoop")], + computation: .init(computedBy: "apple:10.5.1+222", computedAt: stamp.computedAt + 1), deviceId: "my-whoop-noop", from: day, to: day @@ -123,8 +156,20 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { deviceId: "my-whoop-noop", key: "vo2max_est", from: day, to: day) let estimator = try await store.scoreInputSource( deviceId: "my-whoop-noop", day: day, key: "vo2max_est") + let vo2Build = try await store.computationRowForTest( + deviceId: "my-whoop-noop", day: day, key: "vo2max_est") XCTAssertEqual(points.first?.value, 48) XCTAssertEqual(estimator, Vo2MaxEstimator.nes.rawValue) + XCTAssertEqual(vo2Build?.computedBy, stamp.computedBy) + XCTAssertEqual(vo2Build?.scope, .metricSeries) + } + + func testBuildIdentityIncludesPlatformVersionAndBuild() { + XCTAssertEqual( + ScoreComputationStamp.buildIdentity( + platform: "android", appVersion: "10.5.0", appBuild: "221"), + "android:10.5.0+221" + ) } func testMarkerRewriteSpansComputedOwnerNamespaces() async throws { @@ -134,6 +179,7 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { deviceId: "old-noop") try await store.persistComputedScores( dailyMetrics: [makeDaily(day: day, recovery: nil, strain: nil)], metricPoints: [], provenance: [], + computation: .init(computedBy: "test:1+1", computedAt: 1), deviceId: "active-noop", from: day, to: day, replaceMetricKeys: ["day_cycle_onset_ts"], additionalMetricPoints: [.init(deviceId: "old-noop", @@ -142,6 +188,12 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { let old = try await store.metricSeries(deviceId: "old-noop", key: "day_cycle_onset_ts", from: day, to: day) XCTAssertEqual(old.map(\.value), [2]) + let activeStamp = try await store.computationRowForTest( + deviceId: "active-noop", day: day, key: "day_cycle_onset_ts") + let oldStamp = try await store.computationRowForTest( + deviceId: "old-noop", day: day, key: "day_cycle_onset_ts") + XCTAssertNil(activeStamp) + XCTAssertNil(oldStamp) // Additional owners are outside this window's stamp scope. } private func makeDaily(day: String, recovery: Double?, strain: Double?) -> DailyMetric { @@ -161,3 +213,20 @@ final class ScoreInputProvenanceStoreTests: XCTestCase { ) } } + +private extension WhoopStore { + /// Verify the persisted cells directly; tier 3 has no production reader yet. + func computationRowForTest(deviceId: String, day: String, key: String) throws + -> (computedBy: String, computedAt: Int64, scope: ScoreComputationScope)? { + try syncRead { db in + guard let row = try Row.fetchOne(db, sql: """ + SELECT computedBy, computedAt, scope + FROM scoreComputationProvenance + WHERE deviceId = ? AND day = ? AND key = ? + """, arguments: [deviceId, day, key]), + let scope = ScoreComputationScope(rawValue: row["scope"] as String) + else { return nil } + return (computedBy: row["computedBy"], computedAt: row["computedAt"], scope: scope) + } + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMotionStateTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMotionStateTests.swift index 8de52a3e73..4299784f5a 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMotionStateTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/SleepMotionStateTests.swift @@ -25,8 +25,8 @@ final class SleepMotionStateTests: XCTestCase { XCTAssertTrue(cols.contains("sleepStateJSON")) } - func testSchemaVersionBumpedTo18() { - XCTAssertEqual(WhoopStoreInfo.schemaVersion, 18) + func testSchemaVersionBumpedTo19() { + XCTAssertEqual(WhoopStoreInfo.schemaVersion, 19) } // MARK: motionJSON diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index a47bc0fb3f..ce71fdfcba 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -35,6 +35,19 @@ final class IntelligenceEngine: ObservableObject { /// history and the active strap's live data together. So this never moves after construction. private let deviceId: String + /// Exact build + wall-clock identity for one persistence pass. The formatter lives beside the store + /// row so Swift/Kotlin use the same `platform:version+build` representation. + private static func currentComputationStamp(now: Date = Date()) -> ScoreComputationStamp { + let info = Bundle.main.infoDictionary + let version = info?["CFBundleShortVersionString"] as? String ?? "?" + let build = info?["CFBundleVersion"] as? String ?? "?" + return ScoreComputationStamp( + computedBy: ScoreComputationStamp.buildIdentity( + platform: "apple", appVersion: version, appBuild: build), + computedAt: Int64((now.timeIntervalSince1970 * 1_000).rounded()) + ) + } + @Published var results: [Computed] = [] // newest first @Published var computing = false @Published var note: String? @@ -641,6 +654,7 @@ final class IntelligenceEngine: ObservableObject { try? await store.persistMetricSeriesWithProvenance( points: rows, provenance: Self.vo2MaxProvenance(points: rows, waistCm: profile.waistCm), + computation: Self.currentComputationStamp(), deviceId: computedId ) } @@ -2542,6 +2556,7 @@ final class IntelligenceEngine: ObservableObject { } markerSources = sourceIds } + let computation = Self.currentComputationStamp() do { // Repair only the days actually recomputed. A full-history repair must not erase // older cached scores/provenance whose raw inputs are no longer retained. @@ -2560,6 +2575,7 @@ final class IntelligenceEngine: ObservableObject { ? restPointsByDay[from, default: []] : restPoints, provenance: preserveUnscoredHistory ? provenanceByDay[from, default: []] : Array(provenanceByCell.values), + computation: computation, deviceId: computedId, from: from, to: to, replaceMetricKeys: markerKeys, additionalMetricPoints: preserveUnscoredHistory @@ -2619,6 +2635,7 @@ final class IntelligenceEngine: ObservableObject { try? await store.persistMetricSeriesWithProvenance( points: faPts, provenance: Self.vo2MaxProvenance(points: faPts, waistCm: profile.waistCm), + computation: computation, deviceId: computedId ) } @@ -2641,10 +2658,15 @@ final class IntelligenceEngine: ObservableObject { steps: vSteps.isEmpty ? nil : vSteps.reduce(0, +) / Double(vSteps.count)) if let vRes = VitalityEngine.compute(vInputs) { let satKey = IntelligenceEngine.saturdayKey(onOrBefore: newestDay) - _ = try? await store.upsertMetricSeries([ - MetricPoint(day: satKey, key: "vitality", value: vRes.vitality), - MetricPoint(day: satKey, key: "body_age", value: vRes.bodyAge), - ], deviceId: computedId) + try? await store.persistMetricSeriesWithProvenance( + points: [ + MetricPoint(day: satKey, key: "vitality", value: vRes.vitality), + MetricPoint(day: satKey, key: "body_age", value: vRes.bodyAge), + ], + provenance: [], + computation: computation, + deviceId: computedId + ) } markPostLoopPhase("weekly") @@ -2806,7 +2828,14 @@ final class IntelligenceEngine: ObservableObject { let est = StepsEstimateEngine.estimate(motion: motion, calibration: cal) else { continue } estPts.append(MetricPoint(day: dm.day, key: "steps_est", value: Double(est))) } - if !estPts.isEmpty { _ = try? await store.upsertMetricSeries(estPts, deviceId: computedId) } + if !estPts.isEmpty { + try? await store.persistMetricSeriesWithProvenance( + points: estPts, + provenance: [], + computation: computation, + deviceId: computedId + ) + } // Mirror the fit into ProfileStore so the Settings/Steps screen can show + adjust it. profile.stepsCalibrationCoefficient = cal.coefficient profile.stepsCalibrationSampleDays = cal.sampleDays diff --git a/StrandTests/IntelligenceRRSourceTests.swift b/StrandTests/IntelligenceRRSourceTests.swift index e0d01bfecb..39cf4abbcc 100644 --- a/StrandTests/IntelligenceRRSourceTests.swift +++ b/StrandTests/IntelligenceRRSourceTests.swift @@ -104,6 +104,7 @@ final class IntelligenceRRSourceTests: XCTestCase { try await store.persistComputedScores( dailyMetrics: [legacySnapshot], metricPoints: [], provenance: [ScoreInputProvenanceRow(day: input.day, key: "recovery", sourceId: "legacy-owner")], + computation: .init(computedBy: "test:1+1", computedAt: 1), deviceId: canonical + "-noop", from: input.day, to: input.day) _ = try await store.insert(Streams(rr: input.rr), deviceId: active) let legacy = try await score() @@ -155,6 +156,7 @@ final class IntelligenceRRSourceTests: XCTestCase { try await store.persistComputedScores( dailyMetrics: [legacySnapshot], metricPoints: [], provenance: [ScoreInputProvenanceRow(day: input.day, key: "recovery", sourceId: "legacy-owner")], + computation: .init(computedBy: "test:1+1", computedAt: 1), deviceId: canonical + "-noop", from: input.day, to: input.day) let tagged = input.rr.map { RRInterval(ts: $0.ts, rrMs: $0.rrMs, srcChannel: .whoop5Historical) } let inserted = try await store.insert(Streams(rr: tagged), deviceId: active) diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 407341f626..6c36a55b9d 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -300,6 +300,89 @@ "platform": "kotlin", "rationale": "Swift Repository.refresh reads the same imported daily union (unionDailyMetrics) in the excluded app layer and hands it, still apart from the computed union, to ChargeBaselines.resolve (#2525); this disposition does not assert behavioral parity.", "twin_path": "Strand/Data/Repository.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::currentComputationStamp/1#1", + "identity_sha256": "022dc419bd8b30e22dc05c3d30e12ad77551e34270e3644e155598e36121dbe4", + "platform": "kotlin", + "rationale": "The Swift app engine captures the same per-pass build stamp in its currentComputationStamp method; Strand/ is outside the governed Swift roots.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::persistFitnessVitalityAndSteps/16#1", + "identity_sha256": "df6e97e0fb9caaf7c15b177af22172a27e9120fd6cf217366462fc0e6232407f", + "platform": "kotlin", + "rationale": "Kotlin extracts the weekly fitness/vitality/steps persistence from the scoring coroutine; the corresponding pass is inline in the excluded Swift app engine.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt::prepareComputedWindow/13#1", + "identity_sha256": "75ec13ab0fa71b83d7de7674a53d8a9991ec711b6bb74117bccb983399fb91e6", + "platform": "kotlin", + "rationale": "Kotlin constructs the stamped computed window in a helper; Swift assembles the same daily, metric, and source rows inline in the excluded app engine.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt::deleteScoreComputationProvenanceFor/1#1", + "identity_sha256": "a239191aa730a6cc1e96e93f99611105932b5bb72a19731e6748c81ac961c477", + "platform": "kotlin", + "rationale": "Room requires an annotated DAO delete for the new device-scoped table. Swift deletes the table inline through DeviceRegistryStore.deviceScopedTables rather than through a DAO." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt::reKeyScoreComputationProvenance/2#1", + "identity_sha256": "309d9891ca5b94dde621df9af35cf44e713e2ec934e66665453406a80c24e8c9", + "platform": "kotlin", + "rationale": "Room requires a separate annotated DAO update during device identity adoption. Swift iterates its device-scoped tables and rekeys this table inline." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/Entities.kt::scoreComputationProvenanceRows/4#1", + "identity_sha256": "7313feafe3d270d97edc54d5be0012b66868d16d87f4830a78c8b2d6af1b4fc3", + "platform": "kotlin", + "rationale": "Room needs materialized entity rows before its batch @Upsert. Swift deduplicates and writes the corresponding cells inline in WhoopStore.persistComputedScores." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopDao.kt::deleteScoreComputationProvenanceInRange/4#1", + "identity_sha256": "afdeb95897d66d3bf739cb4d8b0b1c80982ba362290cea5b156abea4908dbb7f", + "platform": "kotlin", + "rationale": "Room expresses the score-window deletion as an annotated DAO query. Swift issues the scoped DELETE inline in WhoopStore.persistComputedScores." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopDatabase.kt::migrate/1#40", + "identity_sha256": "f2fcc9a538bee8eaef0fa7a5ada1eb2eb27a1d63ddcd443355b4086ca78cbc0c", + "platform": "kotlin", + "rationale": "This is the Room Migration_41_42 override for Android schema 42. Swift registers the analogous table in a GRDB v48 migration closure, with different platform versioning." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopRepository.kt::replaceComputedScoreWindow/9#1", + "identity_sha256": "321ae2ee2ea64fb3719c0b23d443c5405af922324ab9d4d5533d883328ae60c4", + "platform": "kotlin", + "rationale": "Android repository forwards this call to the Room DAO transaction. Swift WhoopStore exposes the transaction directly; that substantive transaction is paired with the DAO method." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopRepository.kt::upsertMetricSeriesWithProvenance/3#1", + "identity_sha256": "5a9e7f65fd5d1c2b3d075996772cf6751750f877431b7d9eff004b089c3a0cde", + "platform": "kotlin", + "rationale": "Android repository forwards this call to the Room DAO transaction. Swift WhoopStore exposes the transaction directly; that substantive transaction is paired with the DAO method." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 767e162915..a7f1866557 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,15 +19,15 @@ }, "authority": { "files": {"count": 509, "sha256": "0a0b02d39eb26ad0635933ad6fca51fb4c33db98178410bfe6676ab9d0fdcaee"}, - "functions": {"count": 4537, "sha256": "a10084c830d9b91c0f02bb308fa7ac0f1466c7537de3303669e0f4fbeffdf6f2"}, + "functions": {"count": 4547, "sha256": "7107e7ad4a2ee054f58df1f0565ffa3c3206786db5a6a8efdd729b98201350a1"}, "properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"}, "constants": {"count": 2001, "sha256": "a9c1f36d2acc614dd747fd6cc0b3ea48079b81517afda5591d219f9591e353c3"}, - "file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"}, - "function_pairs": {"count": 226, "sha256": "7c7f2298ad7921101241f39d86ecab774d21eadeb89d52d4d5db0397878905f2"}, + "file_pairs": {"count": 83, "sha256": "4c714e19df1cd03a842091ce05eda69289381cea1c1602415cfe0e27b274889c"}, + "function_pairs": {"count": 230, "sha256": "2759a08b1224ea8fa6b282d27c1e04df0f2e45dc3019f262334aafeb9d4810be"}, "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, "constant_pairs": {"count": 702, "sha256": "e3de4c1b84b8500c5700292648bf23dc680e5e14cde055c36c5d1ef4ecc39dc2"}, - "unpaired_files": {"count": 369, "sha256": "3462a2a8135bfd793dc6493f10fb72b4fae9028ad9ec47c8dec84b9f5a4dc01a"}, - "unpaired_functions": {"count": 4098, "sha256": "a7c3215172bf4d1cd33b8df390684d4d65fc1109f8ee52b3d344b04d231f08fd"}, + "unpaired_files": {"count": 367, "sha256": "dc9944a4fc4f67bbdf2a096c0ea6e6dff00c5367aead371da1c96f6626005acb"}, + "unpaired_functions": {"count": 4100, "sha256": "62b6630e83083f1fa7478afddb1b0b0b14672fd60f566bba1d359c47cdd00ddb"}, "unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"}, "unpaired_constants": {"count": 597, "sha256": "578c607cc05b1cd1f867c206c9fab70237ac40707cd49d4e0c5cc66b400184c7"} } diff --git a/Tools/test_score_computation_scopes.py b/Tools/test_score_computation_scopes.py new file mode 100644 index 0000000000..7dc626a920 --- /dev/null +++ b/Tools/test_score_computation_scopes.py @@ -0,0 +1,64 @@ +"""Pin the actual writer key sets that make the single-scope computation PK safe. + +This reads both product sources, so it belongs to the unfiltered core Tools suite. +A new producer/key requires an explicit scope decision instead of silently letting a +window stamp overwrite (and later delete) a standalone stamp for the same cell. +""" +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WINDOW_KEYS = { + "recovery", "strain", "sleep_performance", "spo2_candidate", "hrv_rr_overcount", + "rhr_primary_session", "rhr_primary_session_valid_samples", "rhr_primary_session_duration_s", + "day_cycle_onset_ts", +} +SERIES_KEYS = {"fitness_age", "vo2max_est", "vitality", "body_age", "steps_est"} + + +class ScoreComputationScopesTest(unittest.TestCase): + def test_actual_writer_keys_are_disjoint_on_both_platforms(self): + for language, path, start, end in ( + ("Swift", "Strand/Data/IntelligenceEngine.swift", "var restPoints:", "try await store.persistComputedScores("), + ("Kotlin", "android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt", "val restRows =", "private suspend fun persistFitnessVitalityAndSteps("), + ): + with self.subTest(language=language): + source = (ROOT / path).read_text() + self.assertEqual(1, source.count(start)) + self.assertEqual(1, source.count(end)) + before, window = source.split(start) + window, after = window.split(end) + # Constructors name keys before their value expression; no closing parenthesis + # appears in the day/device arguments at these producer sites. + pattern = r'(?:MetricPoint|MetricSeriesRow)\([^)]*?\bkey\s*[:=]\s*"([^"]+)"' + window_keys = set(re.findall(pattern, window)) | {"day_cycle_onset_ts"} + if language == "Swift": + window_keys |= set(re.findall( + r'ScoreInputProvenanceRow\([^)]*?\bkey:\s*"([^"]+)"', window)) + else: + persistence = (ROOT / "android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt").read_text() + window_keys |= set(re.findall( + r'ScoreInputProvenanceRow\(\s*computedId,\s*daily.day,\s*"([^"]+)"', persistence)) + series_keys = set(re.findall(pattern, before + after)) + self.assertFalse(window_keys & series_keys, "a computation cell must have one owning scope") + self.assertEqual(WINDOW_KEYS, window_keys) + self.assertEqual(SERIES_KEYS, series_keys) + # Pin the dynamic onset key and the daily-score keys alongside the literal series. + if language == "Swift": + cycle = (ROOT / "Strand/Data/DayCycleIntelligenceIntegration.swift").read_text() + self.assertIn('static let onsetKey = "day_cycle_onset_ts"', cycle) + self.assertIn('key: DayCycleIntelligenceIntegration.onsetKey', window) + daily = window + key_syntax = 'key: "{}"' + else: + cycle = (ROOT / "android/app/src/main/java/com/noop/analytics/DayCycleIntelligenceIntegration.kt").read_text() + self.assertIn('const val ONSET_KEY = "day_cycle_onset_ts"', cycle) + daily = (ROOT / "android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt").read_text() + key_syntax = '"{}"' + for key in ("recovery", "strain"): + self.assertIn(key_syntax.format(key), daily) + + +if __name__ == "__main__": + unittest.main() diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 9683d3f290..beaa298ed4 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -1,10 +1,11 @@ package com.noop.analytics -import java.util.Locale +import com.noop.BuildConfig import com.noop.data.DailyMetric import com.noop.data.DeviceBrandCatalog import com.noop.data.MetricSeriesRow import com.noop.data.OuraRespScale +import com.noop.data.ScoreComputationStamp import com.noop.data.ScoreInputProvenanceRow import com.noop.data.SleepSession import com.noop.data.Vo2MaxEstimator @@ -12,6 +13,7 @@ import com.noop.data.WhoopRepository import com.noop.data.WorkoutRow import com.noop.protocol.DeviceFamily import com.noop.protocol.Whoop4SkinTemp +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -43,6 +45,18 @@ import kotlinx.coroutines.withContext */ object IntelligenceEngine { + /** Exact build + wall-clock identity for one persistence pass. Pure identity formatting lives beside + * the storage row so Swift/Kotlin use the same `platform:version+build` representation. */ + private fun currentComputationStamp(nowMs: Long = System.currentTimeMillis()): ScoreComputationStamp = + ScoreComputationStamp( + computedBy = ScoreComputationStamp.buildIdentity( + platform = "android", + appVersion = BuildConfig.VERSION_NAME, + appBuild = BuildConfig.VERSION_CODE.toString(), + ), + computedAt = nowMs, + ) + /** * Serialises [analyzeRecent] against itself. The pass is launched from four independent coroutines: the * 15-min backstop loop and rescoreAfterEdit (both AppViewModel), the post-offload analyze @@ -1941,9 +1955,10 @@ object IntelligenceEngine { // this only fills the days the strap collected but no import covered. // Persist metric-level input provenance in the SAME Room transaction. dayOwnership remains // exclusively a resolver override, and a failed write can never relabel an older score. + val computation = currentComputationStamp() val computedWindow = IntelligencePersistence.prepareComputedWindow( repo, importedDeviceId, computedId, oldestDay, newestDay, dailies, restRows, physiologicalSteps, - candidatePriorities, resolvedScoreOwnerByDay, + candidatePriorities, resolvedScoreOwnerByDay, computation, IntelligencePersistence.LegacyScoreClock(nowLocalMidnight, nowSeconds, tzOffsetSeconds), out, ) IntelligencePersistence.persistComputedWindow(repo, computedWindow, preserveUnscoredHistoryForRun) @@ -1964,6 +1979,7 @@ object IntelligenceEngine { manualStepCoefficient = manualStepCoefficient, persistStepsCalibration = persistStepsCalibration, stepsTraceSink = stepsTraceSink, + computation = computation, ) // DURABILITY GUARD (iOS PR #395 cachedSleepKept): drop any freshly-detected session that // time-overlaps a night the user has already hand-corrected. A detected onset can drift @@ -2098,6 +2114,7 @@ object IntelligenceEngine { manualStepCoefficient: Double?, persistStepsCalibration: (StepsEstimateEngine.Calibration) -> Unit, stepsTraceSink: ((String) -> Unit)?, + computation: ScoreComputationStamp, ) { // #1538: the pass after the day loop was never measured — the cost line brackets the day loop and is // emitted the moment it returns, so the steps calibration re-folding sixty days of gravity every pass @@ -2125,6 +2142,7 @@ object IntelligenceEngine { repo.upsertMetricSeriesWithProvenance( rows = faPts, provenance = vo2MaxProvenance(faPts, profile.waistCm, computedId), + computation = computation, ) } @@ -2145,9 +2163,14 @@ object IntelligenceEngine { steps = if (vSteps.isEmpty()) null else vSteps.average()) VitalityEngine.compute(vInputs)?.let { vRes -> val satKey = saturdayKeyOnOrBefore(newestDay) - repo.upsertMetricSeries(listOf( - MetricSeriesRow(deviceId = computedId, day = satKey, key = "vitality", value = vRes.vitality), - MetricSeriesRow(deviceId = computedId, day = satKey, key = "body_age", value = vRes.bodyAge))) + repo.upsertMetricSeriesWithProvenance( + rows = listOf( + MetricSeriesRow(deviceId = computedId, day = satKey, key = "vitality", value = vRes.vitality), + MetricSeriesRow(deviceId = computedId, day = satKey, key = "body_age", value = vRes.bodyAge), + ), + provenance = emptyList(), + computation = computation, + ) } postLoop.mark("weekly") @@ -2246,7 +2269,13 @@ object IntelligenceEngine { val est = StepsEstimateEngine.estimate(motion, stepsCal) ?: continue estRows.add(MetricSeriesRow(deviceId = computedId, day = dm.day, key = "steps_est", value = est.toDouble())) } - if (estRows.isNotEmpty()) repo.upsertMetricSeries(estRows) + if (estRows.isNotEmpty()) { + repo.upsertMetricSeriesWithProvenance( + rows = estRows, + provenance = emptyList(), + computation = computation, + ) + } // Hand the fit back so the caller mirrors it into ProfileStore for the Settings/Steps screen. persistStepsCalibration(stepsCal) } @@ -2762,6 +2791,7 @@ object IntelligenceEngine { repo.upsertMetricSeriesWithProvenance( rows = rows, provenance = vo2MaxProvenance(rows, profile.waistCm, computedId), + computation = currentComputationStamp(), ) } return rows.isNotEmpty() diff --git a/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt b/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt index 52a52e523f..ab55e83966 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt @@ -2,6 +2,7 @@ package com.noop.analytics import com.noop.data.DailyMetric import com.noop.data.MetricSeriesRow +import com.noop.data.ScoreComputationStamp import com.noop.data.ScoreInputProvenanceRow import com.noop.data.SleepSession import com.noop.data.WhoopRepository @@ -32,6 +33,7 @@ internal object IntelligencePersistence { val dailies: List, val metricRows: List, val provenance: List, + val computation: ScoreComputationStamp, val markerSourceIds: List, ) @@ -68,6 +70,7 @@ internal object IntelligencePersistence { cycle: PhysiologicalStepCycleEngine.Result, candidatePriorities: List>, ownerByDay: Map, + computation: ScoreComputationStamp, legacyClock: LegacyScoreClock, computed: MutableList, ): ComputedWindow { @@ -86,6 +89,7 @@ internal object IntelligencePersistence { metricRows = (metricRows + cycle.recoveredOwnerMarkerRows) .distinctBy { Triple(it.deviceId, it.day, it.key) }, provenance = provenance, + computation = computation, markerSourceIds = DayCycleIntelligenceIntegration.markerRewriteSourceIds( repo.computedSourceIds(importedDeviceId), candidatePriorities.map { (owner, _) -> repo.computedDeviceId(owner) }, @@ -215,6 +219,7 @@ internal suspend fun WhoopRepository.replaceComputedScoreWindow( dailyMetrics = window.dailies, metricPoints = window.metricRows, provenance = window.provenance, + computation = window.computation, replaceMetricKeys = listOf(DayCycleIntelligenceIntegration.ONSET_KEY), replaceMetricSourceIds = window.markerSourceIds, ) diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt index 455cf39430..63b83c1311 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt @@ -137,6 +137,7 @@ class DeviceRegistry( dao.reKeyAppleStepHour(from, to); dao.deleteAppleStepHoursFor(from) dao.reKeyMetricSeries(from, to); dao.deleteMetricSeriesFor(from) dao.reKeyDayOwnership(from, to); dao.deleteDayOwnershipFor(from) + dao.reKeyScoreComputationProvenance(from, to); dao.deleteScoreComputationProvenanceFor(from) dao.reKeySleepStates(from, to); dao.deleteSleepStatesFor(from) dao.reKeyLabMarkers(from, to); dao.deleteLabMarkersFor(from) dao.reKeyLiveSessions(from, to); dao.deleteLiveSessionsFor(from) @@ -223,6 +224,7 @@ class DeviceRegistry( dao.deleteMetricSeriesFor(id) dao.deleteDayOwnershipFor(id) dao.deleteScoreInputProvenanceFor(id) + dao.deleteScoreComputationProvenanceFor(id) dao.deleteSleepStatesFor(id) dao.deleteLabMarkersFor(id) dao.deleteLiveSessionsFor(id) diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt index 84d272e850..e165f0c47a 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt @@ -101,6 +101,8 @@ interface DeviceRegistryDao { @Query("DELETE FROM dayOwnership WHERE deviceId = :deviceId") suspend fun deleteDayOwnershipFor(deviceId: String) @Query("DELETE FROM scoreInputProvenance WHERE deviceId = :deviceId OR sourceId = :deviceId") suspend fun deleteScoreInputProvenanceFor(deviceId: String) + @Query("DELETE FROM scoreComputationProvenance WHERE deviceId = :deviceId") + suspend fun deleteScoreComputationProvenanceFor(deviceId: String) // Added (audit finding): device-keyed tables the delete set previously missed, so "delete all data" // left raw band sleep-state (sleepStateSample), user-entered lab/blood markers (labMarker), live // coaching sessions (liveSession) and dismissed workout/sleep markers behind — a privacy defect for a @@ -154,6 +156,8 @@ interface DeviceRegistryDao { @Query("UPDATE OR IGNORE appleStepHour SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyAppleStepHour(from: String, to: String) @Query("UPDATE OR IGNORE metricSeries SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyMetricSeries(from: String, to: String) @Query("UPDATE OR IGNORE dayOwnership SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyDayOwnership(from: String, to: String) + @Query("UPDATE OR IGNORE scoreComputationProvenance SET deviceId = :to WHERE deviceId = :from") + suspend fun reKeyScoreComputationProvenance(from: String, to: String) @Query("UPDATE OR IGNORE sleepStateSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeySleepStates(from: String, to: String) @Query("UPDATE OR IGNORE labMarker SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLabMarkers(from: String, to: String) @Query("UPDATE OR IGNORE liveSession SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiveSessions(from: String, to: String) diff --git a/android/app/src/main/java/com/noop/data/Entities.kt b/android/app/src/main/java/com/noop/data/Entities.kt index 27e6c7d5ad..e44ddb395b 100644 --- a/android/app/src/main/java/com/noop/data/Entities.kt +++ b/android/app/src/main/java/com/noop/data/Entities.kt @@ -448,6 +448,60 @@ data class ScoreInputProvenanceRow( val sourceId: String, ) +/** Which persistence transaction owns a computation stamp. The daily scoring window replaces only its + * own rows, preserving independently-written weekly/standalone metric-series stamps. */ +enum class ScoreComputationScope(val storageId: String) { + SCORE_WINDOW("score-window"), + METRIC_SERIES("metric-series") +} + +/** Exact app identity and unix-millisecond instant captured for one computation pass. */ +data class ScoreComputationStamp( + val computedBy: String, + val computedAt: Long, +) { + companion object { + /** Cross-platform build identity. Platform is included because restored backups can cross apps. */ + // Swift twin: `ScoreComputationStamp.buildIdentity`. + fun buildIdentity(platform: String, appVersion: String, appBuild: String): String = + "$platform:$appVersion+$appBuild" + } +} + +/** Build provenance for one persisted computed score cell. An absent row is an honest legacy unknown. */ +@Entity( + tableName = "scoreComputationProvenance", + primaryKeys = ["deviceId", "day", "key"], + indices = [Index(name = "idx_scoreComputationProvenance_computedBy", value = ["computedBy"])], +) +data class ScoreComputationProvenanceRow( + val deviceId: String, + val day: String, + @ColumnInfo(name = "key") val key: String, + val computedBy: String, + val computedAt: Long, + val scope: String, +) + +/** De-duplicated, deterministic rows for score cells written by one transaction. */ +internal fun scoreComputationProvenanceRows( + deviceId: String, + cells: List>, + stamp: ScoreComputationStamp, + scope: ScoreComputationScope, +): List = cells.distinct().sortedWith( + compareBy> { it.first }.thenBy { it.second }, +).map { (day, key) -> + ScoreComputationProvenanceRow( + deviceId = deviceId, + day = day, + key = key, + computedBy = stamp.computedBy, + computedAt = stamp.computedAt, + scope = scope.storageId, + ) +} + /** Estimator identity persisted beside a `vo2max_est` point in [ScoreInputProvenanceRow.sourceId]. * Existing points have no such row and therefore remain explicitly unknown; never infer their method * from the user's current profile because a waist measurement may have changed since they were scored. */ diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index 8b0f038d34..de8e2aa67a 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -469,6 +469,10 @@ interface WhoopDao : DeviceRegistryDao { @Upsert suspend fun upsertScoreInputProvenance(rows: List) + // Swift twin: `WhoopStore.upsertComputationProvenance` (same SQLite upsert effect; Room batches rows). + @Upsert + suspend fun upsertScoreComputationProvenance(rows: List) + @Query( "SELECT sourceId FROM scoreInputProvenance " + "WHERE deviceId = :deviceId AND day = :day AND key = :key" @@ -482,15 +486,37 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun deleteScoreInputProvenanceInRange(deviceId: String, from: String, to: String) + @Query( + "DELETE FROM scoreComputationProvenance " + + "WHERE deviceId = :deviceId AND day >= :from AND day <= :to AND scope = :scope" + ) + suspend fun deleteScoreComputationProvenanceInRange( + deviceId: String, + from: String, + to: String, + scope: String, + ) + /** Persist a metric-series batch and its specialized provenance in one transaction. Used by weekly * VO₂max so a method label can never describe an older/newer value after a partial write. */ + // Swift twin: `WhoopStore.persistMetricSeriesWithProvenance`. @Transaction suspend fun upsertMetricSeriesWithProvenance( rows: List, provenance: List, + computation: ScoreComputationStamp, ) { if (rows.isNotEmpty()) upsertMetricSeries(rows) if (provenance.isNotEmpty()) upsertScoreInputProvenance(provenance) + val computationRows = rows.groupBy { it.deviceId }.flatMap { (deviceId, points) -> + scoreComputationProvenanceRows( + deviceId = deviceId, + cells = points.map { it.day to it.key }, + stamp = computation, + scope = ScoreComputationScope.METRIC_SERIES, + ) + } + if (computationRows.isNotEmpty()) upsertScoreComputationProvenance(computationRows) } @Query( @@ -505,6 +531,7 @@ interface WhoopDao : DeviceRegistryDao { * weekly estimator provenance is owned by [upsertMetricSeriesWithProvenance] and survives this daily * window replacement; otherwise a normal re-score would erase the prior two Saturdays' method tags. */ + // Swift twin: `WhoopStore.persistComputedScores`. @Transaction suspend fun replaceComputedScoreWindow( deviceId: String, @@ -513,6 +540,7 @@ interface WhoopDao : DeviceRegistryDao { dailyMetrics: List, metricPoints: List, provenance: List, + computation: ScoreComputationStamp, replaceMetricKeys: List = emptyList(), replaceMetricSourceIds: List = listOf(deviceId), ) { @@ -527,12 +555,23 @@ interface WhoopDao : DeviceRegistryDao { if (dailyMetrics.isEmpty()) return deleteDailyMetricsInRange(deviceId, from, to) deleteScoreInputProvenanceInRange(deviceId, from, to) + deleteScoreComputationProvenanceInRange( + deviceId, from, to, ScoreComputationScope.SCORE_WINDOW.storageId, + ) for (sourceId in replaceMetricSourceIds) { for (key in replaceMetricKeys) deleteMetricSeriesKeyInRange(sourceId, from, to, key) } upsertDailyMetrics(dailyMetrics) if (metricPoints.isNotEmpty()) upsertMetricSeries(metricPoints) if (provenance.isNotEmpty()) upsertScoreInputProvenance(provenance) + val computationRows = scoreComputationProvenanceRows( + deviceId = deviceId, + cells = provenance.map { it.day to it.key } + + metricPoints.filter { it.deviceId == deviceId }.map { it.day to it.key }, + stamp = computation, + scope = ScoreComputationScope.SCORE_WINDOW, + ) + if (computationRows.isNotEmpty()) upsertScoreComputationProvenance(computationRows) } @Upsert diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index 61e4326047..8d39a21e21 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -39,6 +39,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase SleepSession::class, MetricSeriesRow::class, ScoreInputProvenanceRow::class, + ScoreComputationProvenanceRow::class, JournalEntry::class, WorkoutRow::class, DismissedWorkout::class, @@ -59,7 +60,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase LiftSessionRow::class, LiftSetEntity::class, ], - version = 41, + version = 42, // #775: ON so Room's KSP processor writes the generated schema (every table's exact `CREATE TABLE`, // columns in declaration order with affinity/NOT NULL/default, PK and indices) as JSON. That export // is what lets a plain JVM test — no device, no Robolectric — read Android's REAL schema and compare @@ -79,7 +80,7 @@ abstract class WhoopDatabase : RoomDatabase() { const val DB_NAME = "noop_whoop.db" /** Room schema version — MUST equal the `@Database(version = …)` above. Surfaced in the backup * manifest (#1410) so an export states its schema. Bump both together on a migration. */ - const val SCHEMA_VERSION = 41 + const val SCHEMA_VERSION = 42 @Volatile private var instance: WhoopDatabase? = null @@ -908,6 +909,20 @@ abstract class WhoopDatabase : RoomDatabase() { "ALTER TABLE `ppgWaveformSample` ADD COLUMN `burstIndex` INTEGER", ) + /** + * v41 -> v42 (#1410 tier 3): build + time provenance for each computed score cell. Separate from + * scoreInputProvenance, which records the input provider/estimator rather than the code identity. + * Existing history remains absent/unknown because its computing build cannot be reconstructed. + */ + internal val SCORE_COMPUTATION_PROVENANCE_MIGRATION_SQL: List = listOf( + "CREATE TABLE IF NOT EXISTS `scoreComputationProvenance` (`deviceId` TEXT NOT NULL, " + + "`day` TEXT NOT NULL, `key` TEXT NOT NULL, `computedBy` TEXT NOT NULL, " + + "`computedAt` INTEGER NOT NULL, `scope` TEXT NOT NULL, " + + "PRIMARY KEY(`deviceId`, `day`, `key`))", + "CREATE INDEX IF NOT EXISTS `idx_scoreComputationProvenance_computedBy` " + + "ON `scoreComputationProvenance` (`computedBy`)", + ) + internal val MIGRATION_32_33 = object : Migration(32, 33) { override fun migrate(db: SupportSQLiteDatabase) { for (stmt in PPG_BURST_INDEX_MIGRATION_SQL) db.execSQL(stmt) @@ -1100,6 +1115,12 @@ abstract class WhoopDatabase : RoomDatabase() { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(WHOOP5_RR_FILL_MIGRATION_SQL) } } + internal val MIGRATION_41_42 = object : Migration(41, 42) { + override fun migrate(db: SupportSQLiteDatabase) { + for (stmt in SCORE_COMPUTATION_PROVENANCE_MIGRATION_SQL) db.execSQL(stmt) + } + } + /** * Every migration the builder registers, as a VALUE rather than an argument list. * @@ -1126,7 +1147,7 @@ abstract class WhoopDatabase : RoomDatabase() { MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, MIGRATION_34_35, MIGRATION_35_36, - MIGRATION_36_37, MIGRATION_37_38, MIGRATION_38_39, MIGRATION_39_40, MIGRATION_40_41, + MIGRATION_36_37, MIGRATION_37_38, MIGRATION_38_39, MIGRATION_39_40, MIGRATION_40_41, MIGRATION_41_42, ) private fun build(appContext: Context): WhoopDatabase = diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 3eba037006..cdbd1d5ecb 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -793,11 +793,12 @@ class WhoopRepository( dailyMetrics: List, metricPoints: List, provenance: List, + computation: ScoreComputationStamp, replaceMetricKeys: List = emptyList(), replaceMetricSourceIds: List = listOf(deviceId), ) = dao.replaceComputedScoreWindow( deviceId, from, to, dailyMetrics, metricPoints, provenance, - replaceMetricKeys, replaceMetricSourceIds, + computation, replaceMetricKeys, replaceMetricSourceIds, ) /** Computed-only daily rows; unlike daysMerged this can never substitute imported/calendar steps. */ @@ -830,7 +831,8 @@ class WhoopRepository( suspend fun upsertMetricSeriesWithProvenance( rows: List, provenance: List, - ) = dao.upsertMetricSeriesWithProvenance(rows, provenance) + computation: ScoreComputationStamp, + ) = dao.upsertMetricSeriesWithProvenance(rows, provenance, computation) /** Hand-correct the bed (onset) / wake (end) time of an existing sleep session, DURABLY , port * of iOS PR #395 (Repository.editSleepTimes + MetricsCache.applySleepEdit). diff --git a/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt b/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt index f8d5cc11a6..b00374ca70 100644 --- a/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt +++ b/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt @@ -2,6 +2,7 @@ package com.noop.analytics import com.noop.data.DailyMetric import com.noop.data.GravityWitness +import com.noop.data.ScoreComputationStamp import com.noop.data.WhoopDao import com.noop.data.WhoopRepository import java.lang.reflect.InvocationTargetException @@ -93,7 +94,9 @@ class IntelligenceEngineJacocoBudgetTest { """\brepo\s*\.\s*upsertMetricSeriesWithProvenance\s*\(\s*rows\s*=\s*faPts\b""", ), "Vitality compute" to Regex("""\bVitalityEngine\s*\.\s*compute\s*\("""), - "Vitality upsert" to Regex("""\brepo\s*\.\s*upsertMetricSeries\s*\(\s*listOf\s*\("""), + "Vitality upsert" to Regex( + """\brepo\s*\.\s*upsertMetricSeriesWithProvenance\s*\(\s*rows\s*=\s*listOf\s*\(""", + ), "Apple Health read" to Regex("""\brepo\s*\.\s*appleDaily\s*\(\s*WhoopRepository\s*\.\s*APPLE_HEALTH_SOURCE\b"""), "Health Connect read" to Regex("""\brepo\s*\.\s*appleDaily\s*\(\s*WhoopRepository\s*\.\s*HEALTH_CONNECT_SOURCE\b"""), "gravity samples" to Regex("""\brepo\s*\.\s*gravitySamplesForDevice\s*\("""), @@ -101,7 +104,9 @@ class IntelligenceEngineJacocoBudgetTest { // them, so it names the pass that just happened rather than one still running. "steps motion diagnostic" to Regex("""\bdiag\s*\(\s*StepsMotionCache\s*\.\s*logLine\s*\("""), "calibration" to Regex("""\bStepsEstimateEngine\s*\.\s*calibrate\s*\("""), - "step upsert" to Regex("""\brepo\s*\.\s*upsertMetricSeries\s*\(\s*estRows\s*\)"""), + "step upsert" to Regex( + """\brepo\s*\.\s*upsertMetricSeriesWithProvenance\s*\(\s*rows\s*=\s*estRows\b""", + ), "calibration persistence" to Regex("""\bpersistStepsCalibration\s*\("""), "calibration trace" to Regex("""\bStepsEstimateEngineTrace\s*\.\s*calibrationTrace\s*\("""), ) @@ -306,6 +311,7 @@ class IntelligenceEngineJacocoBudgetTest { 1.5, persistCalibration, trace, + ScoreComputationStamp(computedBy = "android:test+1", computedAt = 0), continuation, ) assertEquals(Unit, result) diff --git a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt index bc8d99b125..ead20c6819 100644 --- a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt @@ -78,6 +78,7 @@ class RegistryDayOwnerSourceTest { override suspend fun deleteMetricSeriesFor(deviceId: String) {} override suspend fun deleteDayOwnershipFor(deviceId: String) {} override suspend fun deleteScoreInputProvenanceFor(deviceId: String) {} + override suspend fun deleteScoreComputationProvenanceFor(deviceId: String) {} override suspend fun deleteSleepStatesFor(deviceId: String) {} override suspend fun deleteLabMarkersFor(deviceId: String) {} override suspend fun deleteLiveSessionsFor(deviceId: String) {} @@ -115,6 +116,7 @@ class RegistryDayOwnerSourceTest { override suspend fun reKeyDayOwnership(from: String, to: String) { for ((day, row) in owners) if (row.deviceId == from) owners[day] = row.copy(deviceId = to) } + override suspend fun reKeyScoreComputationProvenance(from: String, to: String) {} override suspend fun reKeySleepStates(from: String, to: String) {} override suspend fun reKeyLabMarkers(from: String, to: String) {} override suspend fun reKeyLiveSessions(from: String, to: String) {} diff --git a/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt b/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt index 8f29dad726..8e8f563adf 100644 --- a/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt +++ b/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt @@ -3,6 +3,7 @@ package com.noop.analytics import com.noop.data.DailyMetric import com.noop.data.MetricSeriesRow import com.noop.data.ScoreInputProvenanceRow +import com.noop.data.ScoreComputationStamp import org.junit.Assert.* import org.junit.Test @@ -22,6 +23,7 @@ class SleepWearHistoryWindowTest { dailies = days.reversed().map { DailyMetric(source, it, totalSleepMin = 400.0) }, metricRows = (days + "2024-03-01").map { MetricSeriesRow(source, it, "sleep_performance", 80.0) }, provenance = days.map { ScoreInputProvenanceRow(source, it, "recovery", "test") }, + computation = ScoreComputationStamp(computedBy = "android:test+1", computedAt = 0), markerSourceIds = listOf(source, "test"), ) val writes = IntelligencePersistence.byScoredDay(window) @@ -31,6 +33,7 @@ class SleepWearHistoryWindowTest { assertTrue(write.dailies.all { it.day == write.from }) assertTrue(write.metricRows.all { it.day == write.from }) assertTrue(write.provenance.all { it.day == write.from }) + assertEquals(window.computation, write.computation) assertEquals(window.markerSourceIds, write.markerSourceIds) } assertTrue(writes.none { "2024-03-01" in it.from..it.to }) diff --git a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt index db16b6ab32..107782ab37 100644 --- a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt +++ b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt @@ -106,6 +106,7 @@ class SourceCoordinatorAdoptionTest { owners.entries.removeIf { it.value.deviceId == deviceId } } override suspend fun deleteScoreInputProvenanceFor(deviceId: String) {} + override suspend fun deleteScoreComputationProvenanceFor(deviceId: String) {} // #771 adopt-serial re-key: sample-table re-keys are unmodelled here (no per-table storage in // this fake), same as the delete*For no-ops above. dayOwnership IS modelled ([owners]), so its @@ -132,6 +133,7 @@ class SourceCoordinatorAdoptionTest { override suspend fun reKeyDayOwnership(from: String, to: String) { for ((day, row) in owners) if (row.deviceId == from) owners[day] = row.copy(deviceId = to) } + override suspend fun reKeyScoreComputationProvenance(from: String, to: String) {} override suspend fun reKeySleepStates(from: String, to: String) {} override suspend fun reKeyLabMarkers(from: String, to: String) {} override suspend fun reKeyLiveSessions(from: String, to: String) {} diff --git a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt index f508a0bc21..f645ec3a73 100644 --- a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt +++ b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt @@ -114,6 +114,9 @@ class DeviceRegistryTest { override suspend fun deleteScoreInputProvenanceFor(deviceId: String) { deletedTables += "scoreInputProvenance" to deviceId } + override suspend fun deleteScoreComputationProvenanceFor(deviceId: String) { + deletedTables += "scoreComputationProvenance" to deviceId + } override suspend fun deleteSleepStatesFor(deviceId: String) { deletedTables += "sleepStateSample" to deviceId } override suspend fun deleteLabMarkersFor(deviceId: String) { deletedTables += "labMarker" to deviceId } override suspend fun deleteLiveSessionsFor(deviceId: String) { deletedTables += "liveSession" to deviceId } @@ -148,6 +151,7 @@ class DeviceRegistryTest { override suspend fun reKeyJournal(from: String, to: String) {} override suspend fun reKeyWorkouts(from: String, to: String) {} override suspend fun reKeyAppleDaily(from: String, to: String) {} + override suspend fun reKeyScoreComputationProvenance(from: String, to: String) {} override suspend fun reKeyAppleStepHour(from: String, to: String) {} override suspend fun reKeyMetricSeries(from: String, to: String) {} override suspend fun reKeyDayOwnership(from: String, to: String) { @@ -325,7 +329,7 @@ class DeviceRegistryTest { "stepSample", "ppgHrSample", "ppgWaveformSample", "v18AuxSample", "event", "battery", "dailyMetric", "sleepSession", "journal", "workout", "appleDaily", "metricSeries", "dayOwnership", - "scoreInputProvenance", + "scoreInputProvenance", "scoreComputationProvenance", "sleepStateSample", "labMarker", "liveSession", "dismissedWorkout", "dismissedSleep", // v38-apple-step-hour: hourly Apple-Health steps. No Android importer writes this table, but a // `.noopbak` restored from iOS carries its rows — and THIS path is "Remove Apple Health diff --git a/android/app/src/test/java/com/noop/data/ScoreComputationDeviceScopeTest.kt b/android/app/src/test/java/com/noop/data/ScoreComputationDeviceScopeTest.kt new file mode 100644 index 0000000000..ad3335d47a --- /dev/null +++ b/android/app/src/test/java/com/noop/data/ScoreComputationDeviceScopeTest.kt @@ -0,0 +1,80 @@ +package com.noop.data + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Proxy + +/** Exercise the DAO transaction bodies with recorded writes, without an Android SQLite runtime. */ +class ScoreComputationDeviceScopeTest { + private val stamp = ScoreComputationStamp("android:test+1", 1234) + private val day = "2026-07-24" + + private class Writes { + val metrics = mutableListOf() + val stamps = mutableListOf() + val dao = Proxy.newProxyInstance( + WhoopDao::class.java.classLoader, arrayOf(WhoopDao::class.java), + ) { proxy, method, args -> + when (method.name) { + "replaceComputedScoreWindow", "upsertMetricSeriesWithProvenance" -> { + // Invoke the production Kotlin interface body, retaining its suspend continuation. + val body = Class.forName("com.noop.data.WhoopDao\$DefaultImpls") + .methods.single { it.name == method.name } + try { body.invoke(null, proxy, *args!!) } + catch (error: InvocationTargetException) { throw error.targetException } + } + "upsertMetricSeries" -> { + @Suppress("UNCHECKED_CAST") + metrics.addAll(args!![0] as List) + Unit + } + "upsertScoreComputationProvenance" -> { + @Suppress("UNCHECKED_CAST") + stamps.addAll(args!![0] as List) + Unit + } + "deleteDailyMetricsInRange", "deleteScoreInputProvenanceInRange", + "deleteScoreComputationProvenanceInRange", "deleteMetricSeriesKeyInRange", + "upsertDailyMetrics", "upsertScoreInputProvenance" -> Unit + else -> error("Unexpected DAO call: ${method.name}") + } + } as WhoopDao + } + + @Test fun foreignOwnerMarkerIsStoredWithoutStampingTheActiveOwner() = runBlocking { + val writes = Writes() + val marker = MetricSeriesRow("ownerB-noop", day, "day_cycle_onset_ts", 100.0) + val own = MetricSeriesRow("ownerA-noop", day, "sleep_performance", 80.0) + writes.dao.replaceComputedScoreWindow( + "ownerA-noop", day, day, listOf(DailyMetric("ownerA-noop", day)), + listOf(marker, own), listOf(ScoreInputProvenanceRow("ownerA-noop", day, "recovery", "ownerA")), stamp, + ) + assertEquals(listOf(marker, own), writes.metrics) + assertEquals(setOf("recovery", "sleep_performance"), writes.stamps.map { it.key }.toSet()) + assertTrue(writes.stamps.all { it.deviceId == "ownerA-noop" && it.scope == "score-window" }) + } + + @Test fun mixedDeviceSeriesStampsOnlyEachDevicesActualCells() = runBlocking { + val writes = Writes() + val rows = listOf( + MetricSeriesRow("ownerB-noop", day, "vo2max_est", 45.0), + MetricSeriesRow("ownerA-noop", day, "steps_est", 1000.0), + MetricSeriesRow("ownerB-noop", day, "steps_est", 2000.0), + ) + writes.dao.upsertMetricSeriesWithProvenance(rows, emptyList(), stamp) + assertEquals(rows, writes.metrics) + assertEquals(rows.map { Triple(it.deviceId, it.day, it.key) }.toSet(), + writes.stamps.map { Triple(it.deviceId, it.day, it.key) }.toSet()) + assertEquals(rows.size, writes.stamps.size) + assertTrue(writes.stamps.all { it.computedBy == stamp.computedBy && it.computedAt == stamp.computedAt && it.scope == "metric-series" }) + } + + @Test fun emptySeriesCreatesNoComputationStamps() = runBlocking { + val writes = Writes() + writes.dao.upsertMetricSeriesWithProvenance(emptyList(), emptyList(), stamp) + assertTrue(writes.metrics.isEmpty()) + assertTrue(writes.stamps.isEmpty()) + } +} diff --git a/android/app/src/test/java/com/noop/data/ScoreComputationProvenanceMigrationTest.kt b/android/app/src/test/java/com/noop/data/ScoreComputationProvenanceMigrationTest.kt new file mode 100644 index 0000000000..1f5ccbb706 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/ScoreComputationProvenanceMigrationTest.kt @@ -0,0 +1,63 @@ +package com.noop.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Guards the additive score-computation provenance schema and deterministic row construction. */ +class ScoreComputationProvenanceMigrationTest { + @Test + fun migrationIsAdditiveAndMatchesEntityShape() { + val sql = WhoopDatabase.SCORE_COMPUTATION_PROVENANCE_MIGRATION_SQL + assertEquals(2, sql.size) + assertEquals( + "CREATE TABLE IF NOT EXISTS `scoreComputationProvenance` (`deviceId` TEXT NOT NULL, " + + "`day` TEXT NOT NULL, `key` TEXT NOT NULL, `computedBy` TEXT NOT NULL, " + + "`computedAt` INTEGER NOT NULL, `scope` TEXT NOT NULL, " + + "PRIMARY KEY(`deviceId`, `day`, `key`))", + sql[0], + ) + assertEquals( + "CREATE INDEX IF NOT EXISTS `idx_scoreComputationProvenance_computedBy` " + + "ON `scoreComputationProvenance` (`computedBy`)", + sql[1], + ) + for (statement in sql) { + val upper = statement.uppercase() + assertTrue(upper.startsWith("CREATE ")) + for (banned in listOf("DROP ", "DELETE ", "UPDATE ", "INSERT ", "ALTER ")) { + assertFalse("migration must not contain $banned", upper.contains(banned)) + } + } + assertEquals(41, WhoopDatabase.MIGRATION_41_42.startVersion) + assertEquals(42, WhoopDatabase.MIGRATION_41_42.endVersion) + assertEquals(42, WhoopDatabase.SCHEMA_VERSION) + } + + @Test + fun rowsAreDeduplicatedOrderedAndStampedAsOnePass() { + val stamp = ScoreComputationStamp( + computedBy = ScoreComputationStamp.buildIdentity("android", "10.5.0", "221"), + computedAt = 1_724_460_000_000, + ) + val rows = scoreComputationProvenanceRows( + deviceId = "my-whoop-noop", + cells = listOf( + "2026-07-25" to "strain", + "2026-07-24" to "recovery", + "2026-07-24" to "recovery", + ), + stamp = stamp, + scope = ScoreComputationScope.SCORE_WINDOW, + ) + + assertEquals("android:10.5.0+221", stamp.computedBy) + assertEquals(2, rows.size) + assertEquals(listOf("2026-07-24", "2026-07-25"), rows.map { it.day }) + assertTrue(rows.all { it.computedBy == stamp.computedBy }) + assertTrue(rows.all { it.computedAt == stamp.computedAt }) + assertTrue(rows.all { it.scope == "score-window" }) + assertEquals("metric-series", ScoreComputationScope.METRIC_SERIES.storageId) + } +} diff --git a/android/app/src/test/resources/roomSchemas/com.noop.data.WhoopDatabase/42.json b/android/app/src/test/resources/roomSchemas/com.noop.data.WhoopDatabase/42.json new file mode 100644 index 0000000000..eb45630442 --- /dev/null +++ b/android/app/src/test/resources/roomSchemas/com.noop.data.WhoopDatabase/42.json @@ -0,0 +1,2227 @@ +{ + "formatVersion": 1, + "database": { + "version": 42, + "identityHash": "d723105c8d05d8fb1c667d1f33072c81", + "entities": [ + { + "tableName": "device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `mac` TEXT, `name` TEXT, `firstSeen` INTEGER, `lastSeen` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mac", + "columnName": "mac", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSeen", + "columnName": "lastSeen", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "hrSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rrInterval", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `rrMs` INTEGER NOT NULL, `seq` INTEGER NOT NULL, `synced` INTEGER NOT NULL, `ord` INTEGER, `srcChannel` INTEGER, `tsSuspect` INTEGER, PRIMARY KEY(`deviceId`, `ts`, `rrMs`, `seq`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rrMs", + "columnName": "rrMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seq", + "columnName": "seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ord", + "columnName": "ord", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "srcChannel", + "columnName": "srcChannel", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tsSuspect", + "columnName": "tsSuspect", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts", + "rrMs", + "seq" + ] + }, + "indices": [ + { + "name": "rrInterval_source_suspect", + "unique": false, + "columnNames": [ + "srcChannel", + "tsSuspect" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `rrInterval_source_suspect` ON `${TABLE_NAME}` (`srcChannel`, `tsSuspect`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `kind` TEXT NOT NULL, `payloadJSON` TEXT NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`, `kind`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payloadJSON", + "columnName": "payloadJSON", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts", + "kind" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "battery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `soc` REAL, `mv` INTEGER, `charging` INTEGER, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "soc", + "columnName": "soc", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "mv", + "columnName": "mv", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charging", + "columnName": "charging", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spo2Sample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `red` INTEGER NOT NULL, `ir` INTEGER NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ir", + "columnName": "ir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "skinTempSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `raw` INTEGER NOT NULL, `synced` INTEGER NOT NULL, `aux1Raw` INTEGER, `aux2Raw` INTEGER, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "aux1Raw", + "columnName": "aux1Raw", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "aux2Raw", + "columnName": "aux2Raw", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "stepSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `counter` INTEGER NOT NULL, `activityClass` INTEGER, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counter", + "columnName": "counter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activityClass", + "columnName": "activityClass", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sleepStateSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `state` INTEGER NOT NULL, `rawByte` INTEGER, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rawByte", + "columnName": "rawByte", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "respSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `raw` INTEGER NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "gravitySample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `x` REAL NOT NULL, `y` REAL NOT NULL, `z` REAL NOT NULL, `synced` INTEGER NOT NULL, `dynAccel` REAL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "z", + "columnName": "z", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dynAccel", + "columnName": "dynAccel", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dailyMetric", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `day` TEXT NOT NULL, `totalSleepMin` REAL, `efficiency` REAL, `deepMin` REAL, `remMin` REAL, `lightMin` REAL, `disturbances` INTEGER, `restingHr` INTEGER, `avgHrv` REAL, `recovery` REAL, `strain` REAL, `exerciseCount` INTEGER, `spo2Pct` REAL, `skinTempDevC` REAL, `respRateBpm` REAL, `steps` INTEGER, `activeKcalEst` REAL, `spo2Red` INTEGER, `spo2Ir` INTEGER, `avgSdnn` REAL, `skinTempC` REAL, `sleepHrOnly` INTEGER, PRIMARY KEY(`deviceId`, `day`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalSleepMin", + "columnName": "totalSleepMin", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "efficiency", + "columnName": "efficiency", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "deepMin", + "columnName": "deepMin", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "remMin", + "columnName": "remMin", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "lightMin", + "columnName": "lightMin", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "disturbances", + "columnName": "disturbances", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "restingHr", + "columnName": "restingHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "avgHrv", + "columnName": "avgHrv", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "recovery", + "columnName": "recovery", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "strain", + "columnName": "strain", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "exerciseCount", + "columnName": "exerciseCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spo2Pct", + "columnName": "spo2Pct", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "skinTempDevC", + "columnName": "skinTempDevC", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "respRateBpm", + "columnName": "respRateBpm", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activeKcalEst", + "columnName": "activeKcalEst", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "spo2Red", + "columnName": "spo2Red", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spo2Ir", + "columnName": "spo2Ir", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "avgSdnn", + "columnName": "avgSdnn", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "skinTempC", + "columnName": "skinTempC", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "sleepHrOnly", + "columnName": "sleepHrOnly", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "day" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sleepSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `startTs` INTEGER NOT NULL, `endTs` INTEGER NOT NULL, `efficiency` REAL, `restingHr` INTEGER, `avgHrv` REAL, `stagesJSON` TEXT, `userEdited` INTEGER NOT NULL, `startTsAdjusted` INTEGER, `motionJSON` TEXT, `sleepStateJSON` TEXT, `stagingSparse` INTEGER, PRIMARY KEY(`deviceId`, `startTs`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "efficiency", + "columnName": "efficiency", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "restingHr", + "columnName": "restingHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "avgHrv", + "columnName": "avgHrv", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "stagesJSON", + "columnName": "stagesJSON", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userEdited", + "columnName": "userEdited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startTsAdjusted", + "columnName": "startTsAdjusted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "motionJSON", + "columnName": "motionJSON", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sleepStateJSON", + "columnName": "sleepStateJSON", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "stagingSparse", + "columnName": "stagingSparse", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "startTs" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "metricSeries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `day` TEXT NOT NULL, `key` TEXT NOT NULL, `value` REAL NOT NULL, PRIMARY KEY(`deviceId`, `day`, `key`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "day", + "key" + ] + }, + "indices": [ + { + "name": "idx_metricSeries_device_key_day", + "unique": false, + "columnNames": [ + "deviceId", + "key", + "day" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_metricSeries_device_key_day` ON `${TABLE_NAME}` (`deviceId`, `key`, `day`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "scoreInputProvenance", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `day` TEXT NOT NULL, `key` TEXT NOT NULL, `sourceId` TEXT NOT NULL, PRIMARY KEY(`deviceId`, `day`, `key`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "day", + "key" + ] + }, + "indices": [ + { + "name": "idx_scoreInputProvenance_source", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_scoreInputProvenance_source` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "scoreComputationProvenance", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `day` TEXT NOT NULL, `key` TEXT NOT NULL, `computedBy` TEXT NOT NULL, `computedAt` INTEGER NOT NULL, `scope` TEXT NOT NULL, PRIMARY KEY(`deviceId`, `day`, `key`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "computedBy", + "columnName": "computedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "computedAt", + "columnName": "computedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "day", + "key" + ] + }, + "indices": [ + { + "name": "idx_scoreComputationProvenance_computedBy", + "unique": false, + "columnNames": [ + "computedBy" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_scoreComputationProvenance_computedBy` ON `${TABLE_NAME}` (`computedBy`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "journal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `day` TEXT NOT NULL, `question` TEXT NOT NULL, `answeredYes` INTEGER NOT NULL, `notes` TEXT, `numericValue` REAL, PRIMARY KEY(`deviceId`, `day`, `question`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "question", + "columnName": "question", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answeredYes", + "columnName": "answeredYes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "numericValue", + "columnName": "numericValue", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "day", + "question" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "workout", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `startTs` INTEGER NOT NULL, `endTs` INTEGER NOT NULL, `sport` TEXT NOT NULL, `source` TEXT NOT NULL, `durationS` REAL, `energyKcal` REAL, `avgHr` INTEGER, `maxHr` INTEGER, `strain` REAL, `distanceM` REAL, `zonesJSON` TEXT, `notes` TEXT, `routePolyline` TEXT, `steps` INTEGER, PRIMARY KEY(`deviceId`, `startTs`, `sport`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sport", + "columnName": "sport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationS", + "columnName": "durationS", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "energyKcal", + "columnName": "energyKcal", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "avgHr", + "columnName": "avgHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxHr", + "columnName": "maxHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "strain", + "columnName": "strain", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "distanceM", + "columnName": "distanceM", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "zonesJSON", + "columnName": "zonesJSON", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "routePolyline", + "columnName": "routePolyline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "startTs", + "sport" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dismissedWorkout", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `startTs` INTEGER NOT NULL, `endTs` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `startTs`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "startTs" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dismissedSleep", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `startTs` INTEGER NOT NULL, `endTs` INTEGER NOT NULL, `managementVisible` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `startTs`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "managementVisible", + "columnName": "managementVisible", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "startTs" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "appleDaily", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `day` TEXT NOT NULL, `steps` INTEGER, `activeKcal` REAL, `basalKcal` REAL, `vo2max` REAL, `avgHr` INTEGER, `maxHr` INTEGER, `walkingHr` INTEGER, `weightKg` REAL, PRIMARY KEY(`deviceId`, `day`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "activeKcal", + "columnName": "activeKcal", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "basalKcal", + "columnName": "basalKcal", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "vo2max", + "columnName": "vo2max", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "avgHr", + "columnName": "avgHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxHr", + "columnName": "maxHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "walkingHr", + "columnName": "walkingHr", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "weightKg", + "columnName": "weightKg", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "day" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ppgHrSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, `conf` REAL NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conf", + "columnName": "conf", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pairedDevice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `brand` TEXT NOT NULL, `model` TEXT NOT NULL, `nickname` TEXT, `peripheralId` TEXT, `sourceKind` TEXT NOT NULL, `capabilities` TEXT NOT NULL, `status` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, `lastSeenAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "brand", + "columnName": "brand", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nickname", + "columnName": "nickname", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "peripheralId", + "columnName": "peripheralId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceKind", + "columnName": "sourceKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenAt", + "columnName": "lastSeenAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dayOwnership", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `locked` INTEGER NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "labMarker", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `markerKey` TEXT NOT NULL, `category` TEXT NOT NULL, `day` TEXT NOT NULL, `takenAt` INTEGER NOT NULL, `value` REAL, `valueText` TEXT, `unit` TEXT NOT NULL, `source` TEXT NOT NULL, `note` TEXT, `referenceText` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markerKey", + "columnName": "markerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "takenAt", + "columnName": "takenAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "valueText", + "columnName": "valueText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referenceText", + "columnName": "referenceText", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_labMarker_natural", + "unique": true, + "columnNames": [ + "deviceId", + "markerKey", + "takenAt", + "source" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `idx_labMarker_natural` ON `${TABLE_NAME}` (`deviceId`, `markerKey`, `takenAt`, `source`)" + }, + { + "name": "idx_labMarker_device_marker_takenAt", + "unique": false, + "columnNames": [ + "deviceId", + "markerKey", + "takenAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_labMarker_device_marker_takenAt` ON `${TABLE_NAME}` (`deviceId`, `markerKey`, `takenAt`)" + }, + { + "name": "idx_labMarker_device_category", + "unique": false, + "columnNames": [ + "deviceId", + "category" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_labMarker_device_category` ON `${TABLE_NAME}` (`deviceId`, `category`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "liveSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `startTs` INTEGER NOT NULL, `endTs` INTEGER, `chargeAtStart` REAL, `floorBpm` REAL NOT NULL, `ceilingBpm` REAL NOT NULL, `inBandSec` REAL NOT NULL, `belowSec` REAL NOT NULL, `aboveSec` REAL NOT NULL, `pushCount` INTEGER NOT NULL, `easeCount` INTEGER NOT NULL, `hrSource` TEXT NOT NULL, PRIMARY KEY(`deviceId`, `startTs`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "chargeAtStart", + "columnName": "chargeAtStart", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "floorBpm", + "columnName": "floorBpm", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "ceilingBpm", + "columnName": "ceilingBpm", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "inBandSec", + "columnName": "inBandSec", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "belowSec", + "columnName": "belowSec", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "aboveSec", + "columnName": "aboveSec", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "pushCount", + "columnName": "pushCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "easeCount", + "columnName": "easeCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hrSource", + "columnName": "hrSource", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "startTs" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ppgWaveformSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `samples` BLOB NOT NULL, `burstIndex` INTEGER, `baseCode` INTEGER, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "samples", + "columnName": "samples", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "burstIndex", + "columnName": "burstIndex", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "baseCode", + "columnName": "baseCode", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "v18AuxSample", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `fields` BLOB NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fields", + "columnName": "fields", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "appleStepHour", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `ts` INTEGER NOT NULL, `steps` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ts", + "columnName": "ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "ts" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "coachMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `role` TEXT NOT NULL, `text` TEXT NOT NULL, `provider` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `orderIndex` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "liftExercise", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `name` TEXT NOT NULL, `primaryMuscle` TEXT, `secondaryMuscles` TEXT, `createdAt` INTEGER NOT NULL, `lastUsedTs` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryMuscle", + "columnName": "primaryMuscle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "secondaryMuscles", + "columnName": "secondaryMuscles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUsedTs", + "columnName": "lastUsedTs", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_liftExercise_natural", + "unique": true, + "columnNames": [ + "deviceId", + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `idx_liftExercise_natural` ON `${TABLE_NAME}` (`deviceId`, `name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "liftProgram", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `name` TEXT NOT NULL, `note` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `archived` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archived", + "columnName": "archived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_liftProgram_device_updatedAt", + "unique": false, + "columnNames": [ + "deviceId", + "updatedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_liftProgram_device_updatedAt` ON `${TABLE_NAME}` (`deviceId`, `updatedAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "liftProgramItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `programId` TEXT NOT NULL, `ord` INTEGER NOT NULL, `exercise` TEXT NOT NULL, `targetSets` INTEGER, `targetRepsLow` INTEGER, `targetRepsHigh` INTEGER, `targetRpe` REAL, `targetWeightKg` REAL, `restSec` INTEGER, `note` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "programId", + "columnName": "programId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ord", + "columnName": "ord", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exercise", + "columnName": "exercise", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetSets", + "columnName": "targetSets", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "targetRepsLow", + "columnName": "targetRepsLow", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "targetRepsHigh", + "columnName": "targetRepsHigh", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "targetRpe", + "columnName": "targetRpe", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "targetWeightKg", + "columnName": "targetWeightKg", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "restSec", + "columnName": "restSec", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_liftProgramItem_device", + "unique": false, + "columnNames": [ + "deviceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_liftProgramItem_device` ON `${TABLE_NAME}` (`deviceId`)" + }, + { + "name": "idx_liftProgramItem_program_ord", + "unique": false, + "columnNames": [ + "programId", + "ord" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_liftProgramItem_program_ord` ON `${TABLE_NAME}` (`programId`, `ord`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "liftSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `startTs` INTEGER NOT NULL, `endTs` INTEGER, `sport` TEXT NOT NULL, `programId` TEXT, `programName` TEXT, `sessionRpe` REAL, `note` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sport", + "columnName": "sport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "programId", + "columnName": "programId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "programName", + "columnName": "programName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionRpe", + "columnName": "sessionRpe", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_liftSession_natural", + "unique": true, + "columnNames": [ + "deviceId", + "startTs", + "sport" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `idx_liftSession_natural` ON `${TABLE_NAME}` (`deviceId`, `startTs`, `sport`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "liftSet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `sessionId` TEXT NOT NULL, `ord` INTEGER NOT NULL, `exercise` TEXT NOT NULL, `primaryMuscle` TEXT, `secondaryMuscles` TEXT, `setIndex` INTEGER NOT NULL, `weightKg` REAL, `reps` INTEGER, `rpe` REAL, `isWarmup` INTEGER NOT NULL DEFAULT 0, `startTs` INTEGER, `endTs` INTEGER, `restSec` INTEGER, `note` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ord", + "columnName": "ord", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exercise", + "columnName": "exercise", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryMuscle", + "columnName": "primaryMuscle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "secondaryMuscles", + "columnName": "secondaryMuscles", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "setIndex", + "columnName": "setIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weightKg", + "columnName": "weightKg", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "reps", + "columnName": "reps", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rpe", + "columnName": "rpe", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "isWarmup", + "columnName": "isWarmup", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "startTs", + "columnName": "startTs", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "endTs", + "columnName": "endTs", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "restSec", + "columnName": "restSec", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_liftSet_device_exercise", + "unique": false, + "columnNames": [ + "deviceId", + "exercise" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_liftSet_device_exercise` ON `${TABLE_NAME}` (`deviceId`, `exercise`)" + }, + { + "name": "idx_liftSet_session_ord", + "unique": false, + "columnNames": [ + "sessionId", + "ord" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_liftSet_session_ord` ON `${TABLE_NAME}` (`sessionId`, `ord`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd723105c8d05d8fb1c667d1f33072c81')" + ] + } +} \ No newline at end of file diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json index 157c04f1f5..3ef7700073 100644 --- a/android/app/src/test/resources/schema_oracle.json +++ b/android/app/src/test/resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_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.", - "roomVersion": 41, + "roomVersion": 42, "grdbMigrations": [ "v1", "v2", @@ -48,7 +48,8 @@ "v44-ppg-waveform-base-code", "v45-rr-source-index", "v46-lift-log", - "v47-rr-whoop5-fill" + "v47-rr-whoop5-fill", + "v48-score-computation-provenance" ], "divergenceReasons": { "android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.", @@ -1878,6 +1879,61 @@ } ] }, + "scoreComputationProvenance": { + "platform": "both", + "columns": [ + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "day", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "key", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "computedBy", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "computedAt", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "scope", + "affinity": "TEXT", + "notNull": true, + "default": null + } + ], + "primaryKey": [ + "deviceId", + "day", + "key" + ], + "indices": [ + { + "name": "idx_scoreComputationProvenance_computedBy", + "unique": false, + "columns": [ + "computedBy" + ] + } + ] + }, "scoreInputProvenance": { "platform": "both", "columns": [