diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift index 8b12b62ee1..a62c089eaf 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift @@ -1096,6 +1096,13 @@ 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) + } return migrator } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift index b6d85c91c6..2498a6ba4a 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift @@ -271,6 +271,17 @@ extension WhoopStore { "device": deviceId, "ts": r.ts, "rr": r.rrMs, "seq": seq]) } } + // #2371: mark the strap's 500 ms fill beats in this batch's window. The batch's heart rate + // was written above, so the same-second rate the rule reads is already on disk. Only a batch + // that carries a 500 ms WHOOP 5 beat pays for the statement. Android runs the same statement + // from `WhoopRepository.insertWithinTransaction`. + let fillTs = streams.rr.filter { + $0.rrMs == 500 && ($0.srcChannel == .whoop5Historical || $0.srcChannel == .whoop5Standard) + }.map(\.ts) + if let fromTs = fillTs.min(), let toTs = fillTs.max() { + try db.execute(sql: WhoopStore.whoop5RrFillFlagSQL, + arguments: ["deviceId": deviceId, "fromTs": fromTs, "toTs": toTs]) + } } if !streams.events.isEmpty { let stmt = try db.cachedStatement(sql: """ diff --git a/Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift b/Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift index 6e9468fd0c..46b87f0957 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift @@ -25,6 +25,26 @@ extension WhoopStore { /// standard BLE (7) already covers beat for beat. static let scorableWhoop5Channels = "(5, 7)" + /// #2371: marks the strap's 500 ms fill beats in one device's ts window. `insert` runs it after a batch + /// that carries a 500 ms WHOOP 5 beat, once that batch's heart rate is on disk. + /// + /// A WHOOP 5/MG emits an exact 500 ms interval (120 bpm) as a filler at rest, on both scored + /// transports: v18 history (5) and standard BLE (7). On one 5.0's backup (443,896 beats, 28 Sep 2026), + /// split by the strap's own heart rate in the same second, 500 ms occurred 33-37 times as often as its + /// neighbours (495-499, 501-505 ms) at 80-94 bpm, 3 times at 95-99, and no more often than them from + /// 100 bpm up (1.7x on four beats at 100-104, none at 105-109, 1.0x at 110-124). Below 100 bpm a + /// 500 ms beat would be at least 17% shorter than the mean interval of its second. + /// + /// The row is MARKED `tsSuspect = 1`, the flag every scoring read already filters (#1073), never + /// deleted: it stays on disk, so the fill stays inspectable. A beat whose second has no heart rate is + /// left alone, since nothing then says it is a fill. One literal, not a composition, so the parity + /// ledger can compare it with the Kotlin twin: `WHOOP5_RR_FILL_FLAG_SQL`. + static let whoop5RrFillFlagSQL = "UPDATE rrInterval SET tsSuspect = 1 WHERE deviceId = :deviceId AND ts >= :fromTs AND ts <= :toTs AND rrMs = 500 AND srcChannel IN (5, 7) AND tsSuspect IS NULL AND EXISTS (SELECT 1 FROM hrSample h WHERE h.deviceId = rrInterval.deviceId AND h.ts = rrInterval.ts AND h.bpm < 100)" + + /// Marks every stored fill beat once, in `v47-rr-whoop5-fill`: the condition of `whoop5RrFillFlagSQL` + /// over the whole table. Kotlin twin: `WHOOP5_RR_FILL_MIGRATION_SQL`. + static let whoop5RrFillMigrationSQL = "UPDATE rrInterval SET tsSuspect = 1 WHERE rrMs = 500 AND srcChannel IN (5, 7) AND tsSuspect IS NULL AND EXISTS (SELECT 1 FROM hrSample h WHERE h.deviceId = rrInterval.deviceId AND h.ts = rrInterval.ts AND h.bpm < 100)" + /// The earliest beat this device has banked that the unit policy can actually score, or nil when it /// has none at all. /// diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json index 4ab143d2b4..157c04f1f5 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": 40, + "roomVersion": 41, "grdbMigrations": [ "v1", "v2", @@ -47,7 +47,8 @@ "v43-coach-messages", "v44-ppg-waveform-base-code", "v45-rr-source-index", - "v46-lift-log" + "v46-lift-log", + "v47-rr-whoop5-fill" ], "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.", diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RrFillTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RrFillTests.swift new file mode 100644 index 0000000000..1e74fb4c8c --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RrFillTests.swift @@ -0,0 +1,117 @@ +import XCTest +import GRDB +import WhoopProtocol +@testable import WhoopStore + +/// #2371: a WHOOP 5/MG sends an exact 500 ms R-R interval as a filler at rest, on v18 history (5) and +/// standard BLE (7). A stored one is MARKED `tsSuspect = 1` when the strap's own heart rate in that second +/// is under 100 bpm: at ingest by `insert`, and for rows already on disk by `v47-rr-whoop5-fill`. Marked, +/// never deleted, so scoring skips it and the raw row stays. Mirrors the Android `Whoop5RrFillTest`. +final class Whoop5RrFillTests: XCTestCase { + + private let device = "my-whoop" + private let t0 = 1_790_000_000 + + /// The statement text is the cross-platform contract: Android runs the same strings, pinned by the + /// same literals in `Whoop5RrFillTest`, so the two stores mark exactly the same rows. + func testTheFillStatementsArePinned() { + let condition = "rrMs = 500 AND srcChannel IN (5, 7) AND tsSuspect IS NULL AND EXISTS (SELECT 1 FROM " + + "hrSample h WHERE h.deviceId = rrInterval.deviceId AND h.ts = rrInterval.ts AND h.bpm < 100)" + XCTAssertEqual(WhoopStore.whoop5RrFillFlagSQL, "UPDATE rrInterval SET tsSuspect = 1 WHERE deviceId = " + + ":deviceId AND ts >= :fromTs AND ts <= :toTs AND " + condition) + XCTAssertEqual(WhoopStore.whoop5RrFillMigrationSQL, "UPDATE rrInterval SET tsSuspect = 1 WHERE " + condition) + } + + /// The whole rule at ingest, one second per case. Only the 500 ms beats on a scored WHOOP 5 transport, + /// in a second whose strap heart rate is under 100, are marked; each neighbour on the other side of + /// one condition is not. + func testIngestMarksOnlyAFillAtRest() async throws { + let store = try await WhoopStore.inMemory() + let hr = [HRSample(ts: t0, bpm: 80), HRSample(ts: t0 + 1, bpm: 99), HRSample(ts: t0 + 2, bpm: 100), + HRSample(ts: t0 + 3, bpm: 80), HRSample(ts: t0 + 4, bpm: 80), HRSample(ts: t0 + 5, bpm: 80), + HRSample(ts: t0 + 6, bpm: 80), HRSample(ts: t0 + 7, bpm: 80)] + let rr = [ + RRInterval(ts: t0, rrMs: 500, srcChannel: .whoop5Historical), // fill: marked + RRInterval(ts: t0, rrMs: 820, srcChannel: .whoop5Historical), // real beat, same second + RRInterval(ts: t0 + 1, rrMs: 500, srcChannel: .whoop5Standard), // fill at 99 bpm: marked + RRInterval(ts: t0 + 2, rrMs: 500, srcChannel: .whoop5Standard), // 100 bpm: a beat, kept + RRInterval(ts: t0 + 3, rrMs: 501, srcChannel: .whoop5Historical), // not 500 + RRInterval(ts: t0 + 4, rrMs: 500, srcChannel: .whoop5Realtime), // channel 6 is never scored + RRInterval(ts: t0 + 5, rrMs: 500, srcChannel: .whoop4Historical), // a WHOOP 4.0 + RRInterval(ts: t0 + 6, rrMs: 500), // no transport label + RRInterval(ts: t0 + 8, rrMs: 500, srcChannel: .whoop5Historical), // no heart rate that second + ] + _ = try await store.insert(Streams(hr: hr, rr: rr), deviceId: device) + + let rows = try await store.fillMarksInTest(deviceId: device) + XCTAssertEqual(rows.count, rr.count, "nothing deleted") + XCTAssertEqual(rows.filter { $0.tsSuspect == 1 }.map { [$0.ts - t0, $0.rrMs] }, [[0, 500], [1, 500]]) + } + + /// A marked fill drops out of the scoring read like any suspect beat, and stays on disk. + func testAMarkedFillIsNotScoredButStaysOnDisk() async throws { + let store = try await WhoopStore.inMemory() + let hr = (0..<3).map { HRSample(ts: t0 + $0, bpm: 75) } + let rr = [RRInterval(ts: t0, rrMs: 800, srcChannel: .whoop5Historical), + RRInterval(ts: t0 + 1, rrMs: 500, srcChannel: .whoop5Historical), + RRInterval(ts: t0 + 2, rrMs: 790, srcChannel: .whoop5Historical)] + _ = try await store.insert(Streams(hr: hr, rr: rr), deviceId: device) + + let scored = try await store.rrIntervals(deviceId: device, from: t0 - 10, to: t0 + 10, limit: 100) + XCTAssertEqual(scored.map(\.rrMs), [800, 790]) + let raw = try await store.fillMarksInTest(deviceId: device) + XCTAssertEqual(raw.map(\.rrMs), [800, 500, 790]) + } + + /// The same beat arriving again (a re-sync) inserts nothing and leaves the mark where it is. + func testAResyncKeepsTheMark() async throws { + let store = try await WhoopStore.inMemory() + let batch = Streams(hr: [HRSample(ts: t0, bpm: 70)], + rr: [RRInterval(ts: t0, rrMs: 500, srcChannel: .whoop5Historical)]) + _ = try await store.insert(batch, deviceId: device) + let again = try await store.insert(batch, deviceId: device) + XCTAssertEqual(again.rr, 0) + let marks = try await store.fillMarksInTest(deviceId: device) + XCTAssertEqual(marks.map(\.tsSuspect), [1]) + } + + /// Rows banked before v47 carry no mark. The migration marks exactly the rows the ingest rule would + /// have, and leaves a future-stamped mark (v35) and every other row alone. + func testV47MarksTheFillsAlreadyStored() async throws { + let t0 = self.t0 + let dbQueue = try DatabaseQueue() + try WhoopStore.makeMigrator().migrate(dbQueue, upTo: "v46-lift-log") + try await dbQueue.write { db in + for (ts, bpm) in [(0, 80), (1, 120), (2, 80), (3, 80)] { + try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('my-whoop', ?, ?)", + arguments: [t0 + ts, bpm]) + } + for (ts, rrMs, channel, suspect) in [(0, 500, 5, nil), (1, 500, 7, nil), (2, 500, 7, nil), + (2, 760, 7, nil), (3, 500, 8, nil), (9, 500, 5, 1)] as [(Int, Int, Int, Int?)] { + try db.execute(sql: """ + INSERT INTO rrInterval (deviceId, ts, rrMs, seq, ord, srcChannel, tsSuspect) + VALUES ('my-whoop', ?, ?, 0, 0, ?, ?) + """, arguments: [t0 + ts, rrMs, channel, suspect]) + } + } + + try WhoopStore.makeMigrator().migrate(dbQueue) + + let marks = try await dbQueue.read { db in + try Row.fetchAll(db, sql: "SELECT ts, rrMs, tsSuspect FROM rrInterval ORDER BY ts, rrMs") + .map { [($0["ts"] as Int) - t0, $0["rrMs"] as Int, ($0["tsSuspect"] as Int?) ?? 0] } + } + XCTAssertEqual(marks, [[0, 500, 1], [1, 500, 0], [2, 500, 1], [2, 760, 0], [3, 500, 0], [9, 500, 1]]) + } +} + +extension WhoopStore { + /// Every stored R-R row for a device with its mark, bypassing the scoring read's filters. Test-only. + func fillMarksInTest(deviceId: String) throws -> [(ts: Int, rrMs: Int, tsSuspect: Int?)] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT ts, rrMs, tsSuspect FROM rrInterval WHERE deviceId = ? ORDER BY ts, ord, rrMs, seq + """, arguments: [deviceId]).map { (ts: $0["ts"], rrMs: $0["rrMs"], tsSuspect: $0["tsSuspect"]) } + } + } +} diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 3a1324a779..8d2c96b3dd 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -105,6 +105,22 @@ "platform": "kotlin", "rationale": "Room @Query DAO method. The Swift store issues the equivalent SQL inline in WhoopStore Reads.swift / StreamStore.swift rather than through a DAO, so there is no Swift function to pair with; its WHOOP 5 siblings whoop5RrIntervals, hasWhoop5RrSource and promoteWhoop5RrSource are one-sided for the same architectural reason." }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopDao.kt::flagWhoop5RrFill/3#1", + "identity_sha256": "3714e95a3d89ac54d74060d037e14dc39a0c6d2219ecb01065471573d7db12bc", + "platform": "kotlin", + "rationale": "Room @Query DAO method (#2371). The Swift store runs the same statement inline in WhoopStore StreamStore.swift rather than through a DAO, so there is no Swift function to pair with; the SQL itself is paired, WHOOP5_RR_FILL_FLAG_SQL with WhoopStore.whoop5RrFillFlagSQL, and pinned by the same literal on both platforms." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopDatabase.kt::migrate/1#39", + "identity_sha256": "81b9152e7189625064e51189ec7be31e1d1f57662438fe54df4c1f779a6bbe48", + "platform": "kotlin", + "rationale": "Room Migration.migrate override of MIGRATION_40_41 (#2371). GRDB registers each migration as a closure (`v47-rr-whoop5-fill`), so there is no Swift function to pair with; the two are paired by the migration identifiers pinned in schema_oracle.json and by the SQL constant pair WHOOP5_RR_FILL_MIGRATION_SQL / WhoopStore.whoop5RrFillMigrationSQL." + }, { "type": "platform_specific", "kind": "add-unpaired-constant", diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 3a4795b275..1c53c8c166 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,15 +19,15 @@ }, "authority": { "files": {"count": 505, "sha256": "581ec0dcab0d8486ce113646cb5cbad71769353eb3aef75f45486e03591733a0"}, - "functions": {"count": 4507, "sha256": "ce9326a53287c00561b1c642cfea7bca0132f8f89bbb9d45711f4c294518d532"}, + "functions": {"count": 4509, "sha256": "828aa64d6abfd21f2dbb94bb552099409555b564dad5d04dbab88d8555466486"}, "properties": {"count": 465, "sha256": "64fcd18c85bf33a4738d154d45ec86967fdc974e78a448d16e9504d9b13609eb"}, - "constants": {"count": 1965, "sha256": "bc257e17e354a48ebbade1e5035aaa41caf21784a119a83488e17270573724da"}, + "constants": {"count": 1969, "sha256": "0cfd548d65ff82bdaa5860c8c63a0f8ac5bacbabc9a5abeb83773c1522147652"}, "file_pairs": {"count": 75, "sha256": "d581f60241ec523c595a7e5ff123f7987caee501d8be4ebf6df5ba105e26fe1f"}, "function_pairs": {"count": 206, "sha256": "5b2e4542dfea5e6e75ae3132e018c66bc12fd77092d0e2c996699c32df20ce0b"}, "property_pairs": {"count": 150, "sha256": "be7168d309c8a9cf5f3f25438c028cc60ab64202bb6e9c09a6c553e3699ad0e2"}, - "constant_pairs": {"count": 683, "sha256": "c3452cb90df3bda98100154f7f2d109b4b473f6358d6ec1d8476ea9fc183eaa3"}, + "constant_pairs": {"count": 685, "sha256": "a949b8f3ffc009b8054d7bc56f4131f7baf134b6f09823f323853d5cfb52c246"}, "unpaired_files": {"count": 377, "sha256": "14e70f77d7e11b4eae3b8412cc493c1aa1bf24dfe8fbc5eca71f81b5daf5253f"}, - "unpaired_functions": {"count": 4108, "sha256": "0aad0d7db52de523a86a5e7438ed4e48bc7a4b54aa1300eb3b56ce907c4616ac"}, + "unpaired_functions": {"count": 4110, "sha256": "16b46b58f2be94f5653bc25ea4099ed8d220a418b0cf193e0d408722c4910708"}, "unpaired_properties": {"count": 165, "sha256": "fe609dd25384cda6de1616d36753fed68431b1a1f9a73b6fd664c7ff35089955"}, "unpaired_constants": {"count": 599, "sha256": "b7467c65623ecc296604558a639d62eee2e00874c56d21acc7ec6d3b6264fdc7"} } 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 4582e39c76..e625f16efa 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -198,6 +198,22 @@ internal const val PROMOTE_WHOOP5_RR_SOURCE_SQL = "AND ((:source = 5 AND (srcChannel IS NULL OR srcChannel IN (6, 7))) " + "OR (:source = 7 AND (srcChannel IS NULL OR srcChannel = 6)))" +/** + * #2371: marks the strap's 500 ms fill beats in one device's ts window, after a batch that carries a 500 ms + * WHOOP 5 beat. A WHOOP 5/MG emits an exact 500 ms interval as a filler at rest on v18 history (5) and + * standard BLE (7); split by the strap's own heart rate in the same second, it is 33-37 times as common as + * its neighbours at 80-94 bpm and no more common from 100 bpm up. The row is MARKED `tsSuspect = 1` (every + * scoring read filters it, #1073), never deleted. One literal, so the parity ledger can compare it with the + * Swift twin `WhoopStore.whoop5RrFillFlagSQL`, which carries the full evidence. + */ +internal const val WHOOP5_RR_FILL_FLAG_SQL = "UPDATE rrInterval SET tsSuspect = 1 WHERE deviceId = :deviceId AND ts >= :fromTs AND ts <= :toTs AND rrMs = 500 AND srcChannel IN (5, 7) AND tsSuspect IS NULL AND EXISTS (SELECT 1 FROM hrSample h WHERE h.deviceId = rrInterval.deviceId AND h.ts = rrInterval.ts AND h.bpm < 100)" + +/** + * Marks every stored fill beat once, in MIGRATION_40_41: the condition of [WHOOP5_RR_FILL_FLAG_SQL] over the + * whole table. Swift twin: `WhoopStore.whoop5RrFillMigrationSQL`. + */ +internal const val WHOOP5_RR_FILL_MIGRATION_SQL = "UPDATE rrInterval SET tsSuspect = 1 WHERE rrMs = 500 AND srcChannel IN (5, 7) AND tsSuspect IS NULL AND EXISTS (SELECT 1 FROM hrSample h WHERE h.deviceId = rrInterval.deviceId AND h.ts = rrInterval.ts AND h.bpm < 100)" + internal const val PROMOTE_WHOOP4_HISTORY_SQL = "UPDATE rrInterval SET srcChannel = 8, ord = :ord " + "WHERE deviceId = :deviceId AND ts = :ts AND rrMs = :rrMs AND seq = :seq AND srcChannel IS NULL" @@ -703,6 +719,10 @@ interface WhoopDao : DeviceRegistryDao { @Query(PROMOTE_WHOOP4_HISTORY_SQL) suspend fun promoteWhoop4HistoricalRr(deviceId: String, ts: Long, rrMs: Int, seq: Int, ord: Int) + /** #2371: mark the WHOOP 5 500 ms fill beats in one device's window ([WHOOP5_RR_FILL_FLAG_SQL]). */ + @Query(WHOOP5_RR_FILL_FLAG_SQL) + suspend fun flagWhoop5RrFill(deviceId: String, fromTs: Long, toTs: Long) + @Query( "SELECT * FROM event WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + "ORDER BY ts ASC, kind ASC LIMIT :limit" 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 34bb40c131..61e4326047 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -59,7 +59,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase LiftSessionRow::class, LiftSetEntity::class, ], - version = 40, + version = 41, // #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 +79,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 = 40 + const val SCHEMA_VERSION = 41 @Volatile private var instance: WhoopDatabase? = null @@ -1091,6 +1091,15 @@ abstract class WhoopDatabase : RoomDatabase() { override fun migrate(db: SupportSQLiteDatabase) { LIFT_LOG_SQL.forEach(db::execSQL) } } + /** + * v40 -> v41 (#2371): mark the WHOOP 5 500 ms fill beats already stored, with the rule the insert path + * applies to every new batch ([WHOOP5_RR_FILL_FLAG_SQL]). Data only, no schema change, and a MARK in + * the v29 form, never a delete. Twin of the GRDB `v47-rr-whoop5-fill`. + */ + internal val MIGRATION_40_41 = object : Migration(40, 41) { + override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(WHOOP5_RR_FILL_MIGRATION_SQL) } + } + /** * Every migration the builder registers, as a VALUE rather than an argument list. * @@ -1117,7 +1126,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_36_37, MIGRATION_37_38, MIGRATION_38_39, MIGRATION_39_40, MIGRATION_40_41, ) 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 60e4da481a..2cc3ccfaf1 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -584,6 +584,16 @@ class WhoopRepository( dao.promoteWhoop4HistoricalRr(row.deviceId, row.ts, row.rrMs, row.seq, row.ord!!) } } + // #2371: mark the strap's 500 ms fill beats in this batch's window. The batch's heart rate was written + // above, so the same-second rate the rule reads is already in the table. Only a batch that carries a + // 500 ms WHOOP 5 beat pays for the statement. iOS runs the same statement from `WhoopStore.insert`. + val fillTs = rrRows.filter { + it.rrMs == 500 && (it.srcChannel == RrSourceChannel.WHOOP5_HISTORICAL.code || + it.srcChannel == RrSourceChannel.WHOOP5_STANDARD.code) + }.map { it.ts } + val fillFrom = fillTs.minOrNull() + val fillTo = fillTs.maxOrNull() + if (fillFrom != null && fillTo != null) dao.flagWhoop5RrFill(deviceId, fillFrom, fillTo) val evIds = if (streams.events.isEmpty()) emptyList() else dao.insertEvents(streams.events.map { EventRow(deviceId, it.ts, it.kind, it.payloadJSON) }) val batIds = if (streams.battery.isEmpty()) emptyList() else diff --git a/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt b/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt index 963e96bfba..4fdbf54d0d 100644 --- a/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt +++ b/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt @@ -119,6 +119,12 @@ class Whoop5RRSqliteTest { .use { it.executeUpdate() } Unit } + "flagWhoop5RrFill" -> { + statement(WHOOP5_RR_FILL_FLAG_SQL, + listOf("deviceId", "fromTs", "toTs").zip(args.take(3)).toMap()) + .use { it.executeUpdate() } + Unit + } "promoteWhoop4HistoricalRr" -> { statement(PROMOTE_WHOOP4_HISTORY_SQL, listOf("deviceId", "ts", "rrMs", "seq", "ord").zip(args.take(5)).toMap()) @@ -742,4 +748,66 @@ class Whoop5RRSqliteTest { assertNotEquals(g0, dao.analysisFingerprint()) assertNotEquals(d0, dao.dayStreamFingerprint(id, 0, 1000)) } + + // #2371: the same cases and the same expected rows as the Swift `Whoop5RrFillTests`, run through the + // production insert path and the production SQL. + private val fillT0 = 1_790_000_000L + private fun storedFillMarks(): List> = query( + "SELECT ts, rrMs, tsSuspect FROM rrInterval WHERE deviceId = :d ORDER BY ts, ord, rrMs, seq", + mapOf("d" to id), + ) { r -> listOf(r.getLong("ts") - fillT0, r.getLong("rrMs"), r.getLong("tsSuspect")) } + + @Test fun whoop5FillAtRestIsMarkedOnInsertAndNothingElse() = runBlocking { + repo.insert(StreamBatch( + hr = listOf(80, 99, 100, 80, 80, 80, 80, 80).mapIndexed { i, bpm -> HrRow(fillT0 + i, bpm) }, + rr = listOf( + RrRow(fillT0, 500, RrSourceChannel.WHOOP5_HISTORICAL), // fill: marked + RrRow(fillT0, 820, RrSourceChannel.WHOOP5_HISTORICAL), // real beat, same second + RrRow(fillT0 + 1, 500, RrSourceChannel.WHOOP5_STANDARD), // fill at 99 bpm: marked + RrRow(fillT0 + 2, 500, RrSourceChannel.WHOOP5_STANDARD), // 100 bpm: a beat, kept + RrRow(fillT0 + 3, 501, RrSourceChannel.WHOOP5_HISTORICAL), // not 500 + RrRow(fillT0 + 4, 500, RrSourceChannel.WHOOP5_REALTIME), // channel 6 is never scored + RrRow(fillT0 + 5, 500, RrSourceChannel.WHOOP4_HISTORICAL), // a WHOOP 4.0 + RrRow(fillT0 + 6, 500), // no transport label + RrRow(fillT0 + 8, 500, RrSourceChannel.WHOOP5_HISTORICAL), // no heart rate that second + ), + ), id) + val rows = storedFillMarks() + assertEquals("nothing deleted", 9, rows.size) + assertEquals(listOf(listOf(0L, 500L), listOf(1L, 500L)), rows.filter { it[2] == 1L }.map { it.take(2) }) + } + + @Test fun whoop5FillIsNotScoredButStaysOnDisk() = runBlocking { + repo.insert(StreamBatch(hr = (0L..2L).map { HrRow(fillT0 + it, 75) }, rr = listOf( + RrRow(fillT0, 800, RrSourceChannel.WHOOP5_HISTORICAL), + RrRow(fillT0 + 1, 500, RrSourceChannel.WHOOP5_HISTORICAL), + RrRow(fillT0 + 2, 790, RrSourceChannel.WHOOP5_HISTORICAL), + )), id) + assertEquals(listOf(800, 790), read(fillT0 - 10, fillT0 + 10).map { it.rrMs }) + assertEquals(listOf(800L, 500L, 790L), storedFillMarks().map { it[1] }) + } + + @Test fun whoop5FillResyncKeepsTheMark() = runBlocking { + val batch = StreamBatch(hr = listOf(HrRow(fillT0, 70)), + rr = listOf(RrRow(fillT0, 500, RrSourceChannel.WHOOP5_HISTORICAL))) + repo.insert(batch, id) + assertEquals(0, repo.insert(batch, id).rr) + assertEquals(listOf(1L), storedFillMarks().map { it[2] }) + } + + @Test fun whoop5FillMigrationMarksTheFillsAlreadyStored() { + listOf(0 to 80, 1 to 120, 2 to 80, 3 to 80).forEach { (ts, bpm) -> + statement("INSERT INTO hrSample VALUES(:d,:t,:b)", mapOf("d" to id, "t" to fillT0 + ts, "b" to bpm)) + .use { it.executeUpdate() } + } + listOf(listOf(0, 500, 5, null), listOf(1, 500, 7, null), listOf(2, 500, 7, null), + listOf(2, 760, 7, null), listOf(3, 500, 8, null), listOf(9, 500, 5, 1)).forEach { (ts, rrMs, ch, sus) -> + statement("INSERT INTO rrInterval(deviceId,ts,rrMs,seq,synced,ord,srcChannel,tsSuspect) " + + "VALUES(:d,:t,:r,0,0,0,:c,:s)", + mapOf("d" to id, "t" to fillT0 + ts!!, "r" to rrMs, "c" to ch, "s" to sus)).use { it.executeUpdate() } + } + sql(WHOOP5_RR_FILL_MIGRATION_SQL) + assertEquals(listOf(listOf(0L, 500L, 1L), listOf(1L, 500L, 0L), listOf(2L, 500L, 1L), + listOf(2L, 760L, 0L), listOf(3L, 500L, 0L), listOf(9L, 500L, 1L)), storedFillMarks()) + } } diff --git a/android/app/src/test/java/com/noop/data/Whoop5RrFillTest.kt b/android/app/src/test/java/com/noop/data/Whoop5RrFillTest.kt new file mode 100644 index 0000000000..4b2b683afb --- /dev/null +++ b/android/app/src/test/java/com/noop/data/Whoop5RrFillTest.kt @@ -0,0 +1,43 @@ +package com.noop.data + +import androidx.sqlite.db.SupportSQLiteDatabase +import java.lang.reflect.Proxy +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * #2371: the WHOOP 5 500 ms fill beat is MARKED `tsSuspect = 1` when the strap's heart rate in its second is + * under 100 bpm. The statement text is the cross-platform contract, pinned here by the same literals as the + * Swift `Whoop5RrFillTests`; the rows it marks are tested against SQLite in [Whoop5RRSqliteTest]. + */ +class Whoop5RrFillTest { + + @Test + fun fillStatementsArePinned() { + val condition = "rrMs = 500 AND srcChannel IN (5, 7) AND tsSuspect IS NULL AND EXISTS (SELECT 1 FROM " + + "hrSample h WHERE h.deviceId = rrInterval.deviceId AND h.ts = rrInterval.ts AND h.bpm < 100)" + assertEquals("UPDATE rrInterval SET tsSuspect = 1 WHERE deviceId = " + + ":deviceId AND ts >= :fromTs AND ts <= :toTs AND " + condition, WHOOP5_RR_FILL_FLAG_SQL) + assertEquals("UPDATE rrInterval SET tsSuspect = 1 WHERE " + condition, WHOOP5_RR_FILL_MIGRATION_SQL) + } + + /** The migration runs exactly the pinned statement, and nothing else: data only, no schema change. */ + @Test + fun migration40to41RunsOnlyTheFillStatement() { + val executed = mutableListOf() + val db = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + if (method.name == "execSQL" && args?.size == 1) executed += args[0] as String + else error("unexpected call during the migration: ${method.name}") + Unit + } as SupportSQLiteDatabase + + WhoopDatabase.MIGRATION_40_41.migrate(db) + + assertEquals(40, WhoopDatabase.MIGRATION_40_41.startVersion) + assertEquals(41, WhoopDatabase.MIGRATION_40_41.endVersion) + assertEquals(listOf(WHOOP5_RR_FILL_MIGRATION_SQL), executed) + } +} diff --git a/android/app/src/test/resources/roomSchemas/com.noop.data.WhoopDatabase/41.json b/android/app/src/test/resources/roomSchemas/com.noop.data.WhoopDatabase/41.json new file mode 100644 index 0000000000..c5ac724ab1 --- /dev/null +++ b/android/app/src/test/resources/roomSchemas/com.noop.data.WhoopDatabase/41.json @@ -0,0 +1,2165 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "420bc286354375011ceffb315cef9850", + "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": "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, '420bc286354375011ceffb315cef9850')" + ] + } +} \ 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 4ab143d2b4..157c04f1f5 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": 40, + "roomVersion": 41, "grdbMigrations": [ "v1", "v2", @@ -47,7 +47,8 @@ "v43-coach-messages", "v44-ppg-waveform-base-code", "v45-rr-source-index", - "v46-lift-log" + "v46-lift-log", + "v47-rr-whoop5-fill" ], "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.",