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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1096,13 +1096,34 @@ 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
// MIGRATION_40_41.
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -74,21 +101,36 @@ 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)
VALUES (?, ?, ?, ?)
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
Expand All @@ -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? {
Expand Down
2 changes: 1 addition & 1 deletion Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ final class LabMarkerStoreTests: XCTestCase {
}
}

func testSchemaVersionIs18() {
XCTAssertEqual(WhoopStoreInfo.schemaVersion, 18)
func testSchemaVersionIs19() {
XCTAssertEqual(WhoopStoreInfo.schemaVersion, 19)
}

// MARK: - helpers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ final class MetricsCacheTests: XCTestCase {
}

func testSchemaVersionBumped() {
XCTAssertEqual(WhoopStoreInfo.schemaVersion, 18)
XCTAssertEqual(WhoopStoreInfo.schemaVersion, 19)
}

// MARK: - sleep sessions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ final class ScaffoldTests: XCTestCase {
}

func testLibraryVersionMarkerPresent() {
XCTAssertEqual(WhoopStoreInfo.schemaVersion, 18)
XCTAssertEqual(WhoopStoreInfo.schemaVersion, 19)
}
}
Loading
Loading