@@ -193,6 +193,12 @@ final class TypeDistributionBin {
|
||
| 193 | 193 |
// Deletions are recorded by sampleUUIDHash because HKDeletedObject exposes UUIDs, |
| 194 | 194 |
// not complete sample payloads. |
| 195 | 195 |
|
| 196 |
+// Interface updated 2026-05-23 — see AGENTS.md |
|
| 197 |
+// HealthArchiveStore exposes SQL-first observation diff APIs: |
|
| 198 |
+// diffSummary(_:) returns appeared/disappeared/representationChanged counts and |
|
| 199 |
+// diffRecords(_:) returns a paged record list for one change kind. UI/cache agents |
|
| 200 |
+// should consume these APIs instead of loading full observation record sets. |
|
| 201 |
+ |
|
| 196 | 202 |
// Storage objective updated 2026-05-23 — see AGENTS.md |
| 197 | 203 |
// Recurring complete snapshots are out of scope for the target architecture. |
| 198 | 204 |
// Store differential observations, versioned sample payloads, observation ranges, |
@@ -25,7 +25,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 25 | 25 |
|------|----------------|--------------------| |
| 26 | 26 |
| Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index | |
| 27 | 27 |
| HealthKit capture | Prototype exists | Adapt capture to write differential SQLite observations first | |
| 28 |
-| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, integrity report, v2 record reads, and initial XCTest coverage are in place; legacy write mirror still exists | Add SQL diff/count queries, large synthetic-data tests, then retire `archive_samples` | |
|
| 28 |
+| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, integrity report, v2 record reads, initial SQL diff/count APIs, and XCTest coverage are in place; legacy write mirror still exists | Add aggregate/provenance SQL analysis, large synthetic-data tests, then retire `archive_samples` | |
|
| 29 | 29 |
| Core Data cache | Not implemented | Add rebuildable cache for expensive counts, summaries, report metadata, UI state | |
| 30 | 30 |
| SwiftData cache | Exists | Treat as disposable prototype data; reset/ignore during v2 transition | |
| 31 | 31 |
| UI | Prototype exists | Reframe screens around observations, diffs, export, archive status | |
@@ -38,7 +38,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 38 | 38 |
|
| 39 | 39 |
Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.md). |
| 40 | 40 |
|
| 41 |
-1. Move large diffs/counts into SQL queries with indexes/temp tables/paged results. |
|
| 41 |
+1. Add aggregate/provenance/consolidation evidence queries on top of the SQLite archive. |
|
| 42 | 42 |
2. Expand the synthetic large-data test harness for diff/export memory behavior. |
| 43 | 43 |
3. Add Core Data UI/report cache and rebuild pipeline. |
| 44 | 44 |
4. Replace SwiftData UI dependencies with Core Data/cache DTOs. |
@@ -54,7 +54,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 54 | 54 |
- Current archive schema is not sufficient as the long-term source of truth. |
| 55 | 55 |
- Existing implementation may decode or cache too much data for low-end devices. |
| 56 | 56 |
- Old prototype database compatibility is no longer required. |
| 57 |
-- Initial SQLite archive tests cover open/init/reset/idempotency, but not yet large-volume diff/export behavior. |
|
| 57 |
+- Initial SQLite archive tests cover open/init/reset/idempotency and small observation diffs, but not yet large-volume diff/export behavior. |
|
| 58 | 58 |
|
| 59 | 59 |
## Verification Checklist |
| 60 | 60 |
|
@@ -145,10 +145,10 @@ Acceptance: |
||
| 145 | 145 |
Checklist: |
| 146 | 146 |
- [x] Implement point-in-time visible-record query. |
| 147 | 147 |
- [x] Implement paged record table query. |
| 148 |
-- [ ] Implement appeared query between observations. |
|
| 149 |
-- [ ] Implement disappeared query between observations. |
|
| 150 |
-- [ ] Implement representationChanged query between observations. |
|
| 151 |
-- [ ] Implement diff counts using temp tables or equivalent SQL-first strategy. |
|
| 148 |
+- [x] Implement appeared query between observations. |
|
| 149 |
+- [x] Implement disappeared query between observations. |
|
| 150 |
+- [x] Implement representationChanged query between observations. |
|
| 151 |
+- [x] Implement diff counts using temp tables or equivalent SQL-first strategy. |
|
| 152 | 152 |
- [ ] Implement aggregate comparison query. |
| 153 | 153 |
- [ ] Implement consolidation-likely evidence query. |
| 154 | 154 |
- [ ] Implement source/provenance breakdown query. |
@@ -157,7 +157,7 @@ Checklist: |
||
| 157 | 157 |
Acceptance: |
| 158 | 158 |
- [x] Observation T can be reconstructed from ranges/events. |
| 159 | 159 |
- [ ] Large diff returns counts and first page without loading all rows. |
| 160 |
-- [ ] Query results are deterministic and ordered. |
|
| 160 |
+- [x] Query results are deterministic and ordered. |
|
| 161 | 161 |
- [ ] Consolidation evidence includes count, aggregate, coverage, density, and uncertainty data. |
| 162 | 162 |
|
| 163 | 163 |
## Milestone 6 - Core Data UI/Report Cache |
@@ -1,12 +1,14 @@ |
||
| 1 | 1 |
import Foundation |
| 2 | 2 |
import HealthKit |
| 3 | 3 |
|
| 4 |
-// Interface updated 2026-05-18 — see AGENTS.md |
|
| 4 |
+// Interface updated 2026-05-23 — see AGENTS.md |
|
| 5 | 5 |
protocol HealthArchiveStore {
|
| 6 | 6 |
func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary |
| 7 | 7 |
func markVerification(sampleType: HKSampleType, verifiedAt: Date) async throws |
| 8 | 8 |
func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws |
| 9 | 9 |
func records(for request: HealthArchiveRecordRequest) async throws -> [ArchivedHealthRecord] |
| 10 |
+ func diffSummary(_ request: HealthArchiveDiffRequest) async throws -> HealthArchiveDiffSummary |
|
| 11 |
+ func diffRecords(_ request: HealthArchiveDiffRecordRequest) async throws -> [ArchivedHealthRecord] |
|
| 10 | 12 |
func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL |
| 11 | 13 |
func checkIntegrity() async throws -> HealthArchiveIntegrityReport |
| 12 | 14 |
} |
@@ -68,6 +70,62 @@ struct RecordCursor: Equatable, Sendable {
|
||
| 68 | 70 |
let strictFingerprint: String |
| 69 | 71 |
} |
| 70 | 72 |
|
| 73 |
+enum HealthArchiveDiffKind: String, Codable, Sendable {
|
|
| 74 |
+ case appeared |
|
| 75 |
+ case disappeared |
|
| 76 |
+ case representationChanged |
|
| 77 |
+} |
|
| 78 |
+ |
|
| 79 |
+struct HealthArchiveDiffRequest: Equatable, Sendable {
|
|
| 80 |
+ let fromObservationID: Int64 |
|
| 81 |
+ let toObservationID: Int64 |
|
| 82 |
+ let sampleTypeIdentifier: String? |
|
| 83 |
+ |
|
| 84 |
+ init(fromObservationID: Int64, toObservationID: Int64, sampleTypeIdentifier: String? = nil) {
|
|
| 85 |
+ self.fromObservationID = fromObservationID |
|
| 86 |
+ self.toObservationID = toObservationID |
|
| 87 |
+ self.sampleTypeIdentifier = sampleTypeIdentifier |
|
| 88 |
+ } |
|
| 89 |
+} |
|
| 90 |
+ |
|
| 91 |
+struct HealthArchiveDiffSummary: Equatable, Sendable {
|
|
| 92 |
+ let fromObservationID: Int64 |
|
| 93 |
+ let toObservationID: Int64 |
|
| 94 |
+ let sampleTypeIdentifier: String? |
|
| 95 |
+ let appearedCount: Int |
|
| 96 |
+ let disappearedCount: Int |
|
| 97 |
+ let representationChangedCount: Int |
|
| 98 |
+ |
|
| 99 |
+ var totalChangeCount: Int {
|
|
| 100 |
+ appearedCount + disappearedCount + representationChangedCount |
|
| 101 |
+ } |
|
| 102 |
+} |
|
| 103 |
+ |
|
| 104 |
+struct HealthArchiveDiffRecordRequest: Equatable, Sendable {
|
|
| 105 |
+ let fromObservationID: Int64 |
|
| 106 |
+ let toObservationID: Int64 |
|
| 107 |
+ let sampleTypeIdentifier: String? |
|
| 108 |
+ let kind: HealthArchiveDiffKind |
|
| 109 |
+ let afterCursor: RecordCursor? |
|
| 110 |
+ let limit: Int? |
|
| 111 |
+ |
|
| 112 |
+ init( |
|
| 113 |
+ fromObservationID: Int64, |
|
| 114 |
+ toObservationID: Int64, |
|
| 115 |
+ sampleTypeIdentifier: String? = nil, |
|
| 116 |
+ kind: HealthArchiveDiffKind, |
|
| 117 |
+ afterCursor: RecordCursor? = nil, |
|
| 118 |
+ limit: Int? = nil |
|
| 119 |
+ ) {
|
|
| 120 |
+ self.fromObservationID = fromObservationID |
|
| 121 |
+ self.toObservationID = toObservationID |
|
| 122 |
+ self.sampleTypeIdentifier = sampleTypeIdentifier |
|
| 123 |
+ self.kind = kind |
|
| 124 |
+ self.afterCursor = afterCursor |
|
| 125 |
+ self.limit = limit |
|
| 126 |
+ } |
|
| 127 |
+} |
|
| 128 |
+ |
|
| 71 | 129 |
struct HealthArchiveReportRequest: Equatable, Sendable {
|
| 72 | 130 |
let reportID: UUID |
| 73 | 131 |
let title: String |
@@ -264,28 +264,196 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 264 | 264 |
|
| 265 | 265 |
var records: [ArchivedHealthRecord] = [] |
| 266 | 266 |
while sqlite3_step(statement) == SQLITE_ROW {
|
| 267 |
- records.append(ArchivedHealthRecord( |
|
| 268 |
- id: columnText(statement, 0) ?? "", |
|
| 269 |
- sampleTypeIdentifier: columnText(statement, 1) ?? "", |
|
| 270 |
- strictFingerprint: columnText(statement, 2) ?? "", |
|
| 271 |
- semanticFingerprint: columnText(statement, 3), |
|
| 272 |
- healthKitUUIDHash: columnText(statement, 4), |
|
| 273 |
- startDate: columnUnixDate(statement, 5) ?? Date(timeIntervalSince1970: 0), |
|
| 274 |
- endDate: columnUnixDate(statement, 6) ?? Date(timeIntervalSince1970: 0), |
|
| 275 |
- firstSeenAt: columnUnixDate(statement, 7) ?? Date(timeIntervalSince1970: 0), |
|
| 276 |
- lastSeenAt: columnUnixDate(statement, 8), |
|
| 277 |
- lastVerifiedAt: columnUnixDate(statement, 9), |
|
| 278 |
- disappearedAt: columnUnixDate(statement, 10), |
|
| 279 |
- valueKind: columnText(statement, 11), |
|
| 280 |
- value: columnDouble(statement, 12), |
|
| 281 |
- unit: columnText(statement, 13), |
|
| 282 |
- categoryValue: columnInt(statement, 14), |
|
| 283 |
- workoutActivityType: columnInt(statement, 15), |
|
| 284 |
- durationSeconds: columnDouble(statement, 16), |
|
| 285 |
- sourceName: nil, |
|
| 286 |
- sourceBundleIdentifier: columnText(statement, 17), |
|
| 287 |
- deviceName: nil |
|
| 288 |
- )) |
|
| 267 |
+ records.append(archiveRecord(from: statement)) |
|
| 268 |
+ } |
|
| 269 |
+ return records |
|
| 270 |
+ } |
|
| 271 |
+ } |
|
| 272 |
+ |
|
| 273 |
+ func diffSummary(_ request: HealthArchiveDiffRequest) async throws -> HealthArchiveDiffSummary {
|
|
| 274 |
+ let db = try openDatabase() |
|
| 275 |
+ defer { sqlite3_close(db) }
|
|
| 276 |
+ try prepareSchemaIfNeeded(db) |
|
| 277 |
+ |
|
| 278 |
+ let typeClause = request.sampleTypeIdentifier == nil ? "" : "AND t.type_identifier = ?" |
|
| 279 |
+ let sql = """ |
|
| 280 |
+ WITH from_visible AS ( |
|
| 281 |
+ SELECT sample_id, version_id |
|
| 282 |
+ FROM sample_visibility_ranges |
|
| 283 |
+ WHERE first_observation_id <= ? |
|
| 284 |
+ AND (last_observation_id IS NULL OR last_observation_id > ?) |
|
| 285 |
+ ), |
|
| 286 |
+ to_visible AS ( |
|
| 287 |
+ SELECT sample_id, version_id |
|
| 288 |
+ FROM sample_visibility_ranges |
|
| 289 |
+ WHERE first_observation_id <= ? |
|
| 290 |
+ AND (last_observation_id IS NULL OR last_observation_id > ?) |
|
| 291 |
+ ) |
|
| 292 |
+ SELECT |
|
| 293 |
+ ( |
|
| 294 |
+ SELECT COUNT(*) |
|
| 295 |
+ FROM to_visible tv |
|
| 296 |
+ LEFT JOIN from_visible fv ON fv.sample_id = tv.sample_id |
|
| 297 |
+ JOIN samples s ON s.id = tv.sample_id |
|
| 298 |
+ JOIN sample_types t ON t.id = s.sample_type_id |
|
| 299 |
+ WHERE fv.sample_id IS NULL \(typeClause) |
|
| 300 |
+ ) AS appeared_count, |
|
| 301 |
+ ( |
|
| 302 |
+ SELECT COUNT(*) |
|
| 303 |
+ FROM from_visible fv |
|
| 304 |
+ LEFT JOIN to_visible tv ON tv.sample_id = fv.sample_id |
|
| 305 |
+ JOIN samples s ON s.id = fv.sample_id |
|
| 306 |
+ JOIN sample_types t ON t.id = s.sample_type_id |
|
| 307 |
+ WHERE tv.sample_id IS NULL \(typeClause) |
|
| 308 |
+ ) AS disappeared_count, |
|
| 309 |
+ ( |
|
| 310 |
+ SELECT COUNT(*) |
|
| 311 |
+ FROM to_visible tv |
|
| 312 |
+ JOIN from_visible fv ON fv.sample_id = tv.sample_id |
|
| 313 |
+ JOIN samples s ON s.id = tv.sample_id |
|
| 314 |
+ JOIN sample_types t ON t.id = s.sample_type_id |
|
| 315 |
+ WHERE tv.version_id != fv.version_id \(typeClause) |
|
| 316 |
+ ) AS representation_changed_count |
|
| 317 |
+ """ |
|
| 318 |
+ |
|
| 319 |
+ return try withStatement(sql, db: db) { statement in
|
|
| 320 |
+ var index: Int32 = 1 |
|
| 321 |
+ bindDiffObservationIDs(request.fromObservationID, request.toObservationID, to: statement, startingAt: &index) |
|
| 322 |
+ for _ in 0..<3 {
|
|
| 323 |
+ if let sampleTypeIdentifier = request.sampleTypeIdentifier {
|
|
| 324 |
+ bindText(sampleTypeIdentifier, to: index, in: statement) |
|
| 325 |
+ index += 1 |
|
| 326 |
+ } |
|
| 327 |
+ } |
|
| 328 |
+ |
|
| 329 |
+ guard sqlite3_step(statement) == SQLITE_ROW else {
|
|
| 330 |
+ return HealthArchiveDiffSummary( |
|
| 331 |
+ fromObservationID: request.fromObservationID, |
|
| 332 |
+ toObservationID: request.toObservationID, |
|
| 333 |
+ sampleTypeIdentifier: request.sampleTypeIdentifier, |
|
| 334 |
+ appearedCount: 0, |
|
| 335 |
+ disappearedCount: 0, |
|
| 336 |
+ representationChangedCount: 0 |
|
| 337 |
+ ) |
|
| 338 |
+ } |
|
| 339 |
+ return HealthArchiveDiffSummary( |
|
| 340 |
+ fromObservationID: request.fromObservationID, |
|
| 341 |
+ toObservationID: request.toObservationID, |
|
| 342 |
+ sampleTypeIdentifier: request.sampleTypeIdentifier, |
|
| 343 |
+ appearedCount: columnInt(statement, 0) ?? 0, |
|
| 344 |
+ disappearedCount: columnInt(statement, 1) ?? 0, |
|
| 345 |
+ representationChangedCount: columnInt(statement, 2) ?? 0 |
|
| 346 |
+ ) |
|
| 347 |
+ } |
|
| 348 |
+ } |
|
| 349 |
+ |
|
| 350 |
+ func diffRecords(_ request: HealthArchiveDiffRecordRequest) async throws -> [ArchivedHealthRecord] {
|
|
| 351 |
+ let db = try openDatabase() |
|
| 352 |
+ defer { sqlite3_close(db) }
|
|
| 353 |
+ try prepareSchemaIfNeeded(db) |
|
| 354 |
+ |
|
| 355 |
+ let selectedRangeSQL: String |
|
| 356 |
+ switch request.kind {
|
|
| 357 |
+ case .appeared: |
|
| 358 |
+ selectedRangeSQL = """ |
|
| 359 |
+ SELECT tv.sample_id, tv.version_id |
|
| 360 |
+ FROM to_visible tv |
|
| 361 |
+ LEFT JOIN from_visible fv ON fv.sample_id = tv.sample_id |
|
| 362 |
+ WHERE fv.sample_id IS NULL |
|
| 363 |
+ """ |
|
| 364 |
+ case .disappeared: |
|
| 365 |
+ selectedRangeSQL = """ |
|
| 366 |
+ SELECT fv.sample_id, fv.version_id |
|
| 367 |
+ FROM from_visible fv |
|
| 368 |
+ LEFT JOIN to_visible tv ON tv.sample_id = fv.sample_id |
|
| 369 |
+ WHERE tv.sample_id IS NULL |
|
| 370 |
+ """ |
|
| 371 |
+ case .representationChanged: |
|
| 372 |
+ selectedRangeSQL = """ |
|
| 373 |
+ SELECT tv.sample_id, tv.version_id |
|
| 374 |
+ FROM to_visible tv |
|
| 375 |
+ JOIN from_visible fv ON fv.sample_id = tv.sample_id |
|
| 376 |
+ WHERE tv.version_id != fv.version_id |
|
| 377 |
+ """ |
|
| 378 |
+ } |
|
| 379 |
+ |
|
| 380 |
+ var clauses: [String] = [] |
|
| 381 |
+ if request.sampleTypeIdentifier != nil {
|
|
| 382 |
+ clauses.append("t.type_identifier = ?")
|
|
| 383 |
+ } |
|
| 384 |
+ if request.afterCursor != nil {
|
|
| 385 |
+ clauses.append("(v.start_date > ? OR (v.start_date = ? AND s.strict_fingerprint > ?))")
|
|
| 386 |
+ } |
|
| 387 |
+ let whereClause = clauses.isEmpty ? "" : "WHERE \(clauses.joined(separator: " AND "))" |
|
| 388 |
+ let limitClause = request.limit.map { "LIMIT \(max($0, 0))" } ?? ""
|
|
| 389 |
+ let sql = """ |
|
| 390 |
+ WITH from_visible AS ( |
|
| 391 |
+ SELECT sample_id, version_id |
|
| 392 |
+ FROM sample_visibility_ranges |
|
| 393 |
+ WHERE first_observation_id <= ? |
|
| 394 |
+ AND (last_observation_id IS NULL OR last_observation_id > ?) |
|
| 395 |
+ ), |
|
| 396 |
+ to_visible AS ( |
|
| 397 |
+ SELECT sample_id, version_id |
|
| 398 |
+ FROM sample_visibility_ranges |
|
| 399 |
+ WHERE first_observation_id <= ? |
|
| 400 |
+ AND (last_observation_id IS NULL OR last_observation_id > ?) |
|
| 401 |
+ ), |
|
| 402 |
+ selected_ranges AS ( |
|
| 403 |
+ \(selectedRangeSQL) |
|
| 404 |
+ ), |
|
| 405 |
+ event_summary AS ( |
|
| 406 |
+ SELECT |
|
| 407 |
+ sample_id, |
|
| 408 |
+ MAX(CASE WHEN event_kind != 'disappeared' THEN observed_at END) AS last_seen_at, |
|
| 409 |
+ MAX(observed_at) AS last_verified_at, |
|
| 410 |
+ MAX(CASE WHEN event_kind = 'disappeared' THEN observed_at END) AS disappeared_at |
|
| 411 |
+ FROM sample_observation_events |
|
| 412 |
+ WHERE observation_id <= ? |
|
| 413 |
+ GROUP BY sample_id |
|
| 414 |
+ ) |
|
| 415 |
+ SELECT |
|
| 416 |
+ COALESCE(s.sample_uuid_hash, s.strict_fingerprint) AS record_id, |
|
| 417 |
+ t.type_identifier, s.strict_fingerprint, s.semantic_fingerprint, s.sample_uuid_hash, |
|
| 418 |
+ v.start_date, v.end_date, s.first_seen_at, |
|
| 419 |
+ COALESCE(es.last_seen_at, s.first_seen_at) AS last_seen_at, |
|
| 420 |
+ es.last_verified_at, |
|
| 421 |
+ es.disappeared_at, |
|
| 422 |
+ v.value_kind, v.numeric_value, v.unit, v.category_value, v.workout_activity_type, v.duration_seconds, |
|
| 423 |
+ src.bundle_identifier |
|
| 424 |
+ FROM selected_ranges srng |
|
| 425 |
+ JOIN samples s ON s.id = srng.sample_id |
|
| 426 |
+ JOIN sample_types t ON t.id = s.sample_type_id |
|
| 427 |
+ JOIN sample_versions v ON v.id = srng.version_id |
|
| 428 |
+ LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id |
|
| 429 |
+ LEFT JOIN sources src ON src.id = sr.source_id |
|
| 430 |
+ LEFT JOIN event_summary es ON es.sample_id = s.id |
|
| 431 |
+ \(whereClause) |
|
| 432 |
+ ORDER BY v.start_date ASC, s.strict_fingerprint ASC |
|
| 433 |
+ \(limitClause) |
|
| 434 |
+ """ |
|
| 435 |
+ |
|
| 436 |
+ return try withStatement(sql, db: db) { statement in
|
|
| 437 |
+ var index: Int32 = 1 |
|
| 438 |
+ bindDiffObservationIDs(request.fromObservationID, request.toObservationID, to: statement, startingAt: &index) |
|
| 439 |
+ bindInt64(request.toObservationID, to: index, in: statement) |
|
| 440 |
+ index += 1 |
|
| 441 |
+ if let sampleTypeIdentifier = request.sampleTypeIdentifier {
|
|
| 442 |
+ bindText(sampleTypeIdentifier, to: index, in: statement) |
|
| 443 |
+ index += 1 |
|
| 444 |
+ } |
|
| 445 |
+ if let cursor = request.afterCursor {
|
|
| 446 |
+ sqlite3_bind_double(statement, index, cursor.startDate.timeIntervalSince1970) |
|
| 447 |
+ index += 1 |
|
| 448 |
+ sqlite3_bind_double(statement, index, cursor.startDate.timeIntervalSince1970) |
|
| 449 |
+ index += 1 |
|
| 450 |
+ bindText(cursor.strictFingerprint, to: index, in: statement) |
|
| 451 |
+ index += 1 |
|
| 452 |
+ } |
|
| 453 |
+ |
|
| 454 |
+ var records: [ArchivedHealthRecord] = [] |
|
| 455 |
+ while sqlite3_step(statement) == SQLITE_ROW {
|
|
| 456 |
+ records.append(archiveRecord(from: statement)) |
|
| 289 | 457 |
} |
| 290 | 458 |
return records |
| 291 | 459 |
} |
@@ -1570,6 +1738,47 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 1570 | 1738 |
} |
| 1571 | 1739 |
} |
| 1572 | 1740 |
|
| 1741 |
+ private func bindDiffObservationIDs( |
|
| 1742 |
+ _ fromObservationID: Int64, |
|
| 1743 |
+ _ toObservationID: Int64, |
|
| 1744 |
+ to statement: OpaquePointer?, |
|
| 1745 |
+ startingAt index: inout Int32 |
|
| 1746 |
+ ) {
|
|
| 1747 |
+ bindInt64(fromObservationID, to: index, in: statement) |
|
| 1748 |
+ index += 1 |
|
| 1749 |
+ bindInt64(fromObservationID, to: index, in: statement) |
|
| 1750 |
+ index += 1 |
|
| 1751 |
+ bindInt64(toObservationID, to: index, in: statement) |
|
| 1752 |
+ index += 1 |
|
| 1753 |
+ bindInt64(toObservationID, to: index, in: statement) |
|
| 1754 |
+ index += 1 |
|
| 1755 |
+ } |
|
| 1756 |
+ |
|
| 1757 |
+ private func archiveRecord(from statement: OpaquePointer?) -> ArchivedHealthRecord {
|
|
| 1758 |
+ ArchivedHealthRecord( |
|
| 1759 |
+ id: columnText(statement, 0) ?? "", |
|
| 1760 |
+ sampleTypeIdentifier: columnText(statement, 1) ?? "", |
|
| 1761 |
+ strictFingerprint: columnText(statement, 2) ?? "", |
|
| 1762 |
+ semanticFingerprint: columnText(statement, 3), |
|
| 1763 |
+ healthKitUUIDHash: columnText(statement, 4), |
|
| 1764 |
+ startDate: columnUnixDate(statement, 5) ?? Date(timeIntervalSince1970: 0), |
|
| 1765 |
+ endDate: columnUnixDate(statement, 6) ?? Date(timeIntervalSince1970: 0), |
|
| 1766 |
+ firstSeenAt: columnUnixDate(statement, 7) ?? Date(timeIntervalSince1970: 0), |
|
| 1767 |
+ lastSeenAt: columnUnixDate(statement, 8), |
|
| 1768 |
+ lastVerifiedAt: columnUnixDate(statement, 9), |
|
| 1769 |
+ disappearedAt: columnUnixDate(statement, 10), |
|
| 1770 |
+ valueKind: columnText(statement, 11), |
|
| 1771 |
+ value: columnDouble(statement, 12), |
|
| 1772 |
+ unit: columnText(statement, 13), |
|
| 1773 |
+ categoryValue: columnInt(statement, 14), |
|
| 1774 |
+ workoutActivityType: columnInt(statement, 15), |
|
| 1775 |
+ durationSeconds: columnDouble(statement, 16), |
|
| 1776 |
+ sourceName: nil, |
|
| 1777 |
+ sourceBundleIdentifier: columnText(statement, 17), |
|
| 1778 |
+ deviceName: nil |
|
| 1779 |
+ ) |
|
| 1780 |
+ } |
|
| 1781 |
+ |
|
| 1573 | 1782 |
private func requiredInt64( |
| 1574 | 1783 |
_ sql: String, |
| 1575 | 1784 |
db: OpaquePointer?, |
@@ -77,6 +77,59 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
||
| 77 | 77 |
XCTAssertTrue(report.passed) |
| 78 | 78 |
} |
| 79 | 79 |
|
| 80 |
+ func testDiffSummaryAndRecordsBetweenObservationsUseSQLVisibility() async throws {
|
|
| 81 |
+ let url = databaseURL() |
|
| 82 |
+ let store = SQLiteHealthArchiveStore(databaseURL: url) |
|
| 83 |
+ let firstSample = makeStepCountSample(value: 42, start: 1_000) |
|
| 84 |
+ let secondSample = makeStepCountSample(value: 7, start: 2_000) |
|
| 85 |
+ let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue |
|
| 86 |
+ |
|
| 87 |
+ _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000)) |
|
| 88 |
+ _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060)) |
|
| 89 |
+ try await store.recordDisappearance( |
|
| 90 |
+ sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString), |
|
| 91 |
+ sampleTypeIdentifier: typeIdentifier, |
|
| 92 |
+ observedMissingAt: Date(timeIntervalSince1970: 3_120) |
|
| 93 |
+ ) |
|
| 94 |
+ let observationIDs = try observationIDs(at: url) |
|
| 95 |
+ XCTAssertEqual(observationIDs.count, 3) |
|
| 96 |
+ |
|
| 97 |
+ let appearedSummary = try await store.diffSummary(HealthArchiveDiffRequest( |
|
| 98 |
+ fromObservationID: observationIDs[0], |
|
| 99 |
+ toObservationID: observationIDs[1], |
|
| 100 |
+ sampleTypeIdentifier: typeIdentifier |
|
| 101 |
+ )) |
|
| 102 |
+ let appearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest( |
|
| 103 |
+ fromObservationID: observationIDs[0], |
|
| 104 |
+ toObservationID: observationIDs[1], |
|
| 105 |
+ sampleTypeIdentifier: typeIdentifier, |
|
| 106 |
+ kind: .appeared, |
|
| 107 |
+ limit: 10 |
|
| 108 |
+ )) |
|
| 109 |
+ let disappearedSummary = try await store.diffSummary(HealthArchiveDiffRequest( |
|
| 110 |
+ fromObservationID: observationIDs[1], |
|
| 111 |
+ toObservationID: observationIDs[2], |
|
| 112 |
+ sampleTypeIdentifier: typeIdentifier |
|
| 113 |
+ )) |
|
| 114 |
+ let disappearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest( |
|
| 115 |
+ fromObservationID: observationIDs[1], |
|
| 116 |
+ toObservationID: observationIDs[2], |
|
| 117 |
+ sampleTypeIdentifier: typeIdentifier, |
|
| 118 |
+ kind: .disappeared, |
|
| 119 |
+ limit: 10 |
|
| 120 |
+ )) |
|
| 121 |
+ |
|
| 122 |
+ XCTAssertEqual(appearedSummary.appearedCount, 1) |
|
| 123 |
+ XCTAssertEqual(appearedSummary.disappearedCount, 0) |
|
| 124 |
+ XCTAssertEqual(appearedSummary.representationChangedCount, 0) |
|
| 125 |
+ XCTAssertEqual(appearedRecords.map(\.displayValue), ["7.0 count"]) |
|
| 126 |
+ XCTAssertEqual(disappearedSummary.appearedCount, 0) |
|
| 127 |
+ XCTAssertEqual(disappearedSummary.disappearedCount, 1) |
|
| 128 |
+ XCTAssertEqual(disappearedSummary.representationChangedCount, 0) |
|
| 129 |
+ XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"]) |
|
| 130 |
+ XCTAssertNotNil(disappearedRecords.first?.disappearedAt) |
|
| 131 |
+ } |
|
| 132 |
+ |
|
| 80 | 133 |
private func databaseURL() -> URL {
|
| 81 | 134 |
temporaryDirectory.appending(path: "Archive.sqlite") |
| 82 | 135 |
} |
@@ -94,11 +147,15 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
||
| 94 | 147 |
} |
| 95 | 148 |
|
| 96 | 149 |
private func makeStepCountSample() -> HKQuantitySample {
|
| 150 |
+ makeStepCountSample(value: 42, start: 1_000) |
|
| 151 |
+ } |
|
| 152 |
+ |
|
| 153 |
+ private func makeStepCountSample(value: Double, start: TimeInterval) -> HKQuantitySample {
|
|
| 97 | 154 |
let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)! |
| 98 |
- let quantity = HKQuantity(unit: .count(), doubleValue: 42) |
|
| 99 |
- let start = Date(timeIntervalSince1970: 1_000) |
|
| 100 |
- let end = Date(timeIntervalSince1970: 1_300) |
|
| 101 |
- return HKQuantitySample(type: quantityType, quantity: quantity, start: start, end: end) |
|
| 155 |
+ let quantity = HKQuantity(unit: .count(), doubleValue: value) |
|
| 156 |
+ let startDate = Date(timeIntervalSince1970: start) |
|
| 157 |
+ let endDate = Date(timeIntervalSince1970: start + 300) |
|
| 158 |
+ return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate) |
|
| 102 | 159 |
} |
| 103 | 160 |
|
| 104 | 161 |
private func countRows(in tableName: String, at url: URL) throws -> Int {
|
@@ -124,6 +181,30 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
||
| 124 | 181 |
return Int(sqlite3_column_int(statement, 0)) |
| 125 | 182 |
} |
| 126 | 183 |
|
| 184 |
+ private func observationIDs(at url: URL) throws -> [Int64] {
|
|
| 185 |
+ var db: OpaquePointer? |
|
| 186 |
+ guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
|
|
| 187 |
+ sqlite3_close(db) |
|
| 188 |
+ XCTFail("Could not open test database")
|
|
| 189 |
+ return [] |
|
| 190 |
+ } |
|
| 191 |
+ defer { sqlite3_close(db) }
|
|
| 192 |
+ |
|
| 193 |
+ var statement: OpaquePointer? |
|
| 194 |
+ guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
|
|
| 195 |
+ sqlite3_finalize(statement) |
|
| 196 |
+ XCTFail("Could not prepare observation query")
|
|
| 197 |
+ return [] |
|
| 198 |
+ } |
|
| 199 |
+ defer { sqlite3_finalize(statement) }
|
|
| 200 |
+ |
|
| 201 |
+ var ids: [Int64] = [] |
|
| 202 |
+ while sqlite3_step(statement) == SQLITE_ROW {
|
|
| 203 |
+ ids.append(sqlite3_column_int64(statement, 0)) |
|
| 204 |
+ } |
|
| 205 |
+ return ids |
|
| 206 |
+ } |
|
| 207 |
+ |
|
| 127 | 208 |
private func sampleVersionDebugRows(at url: URL) throws -> String {
|
| 128 | 209 |
var db: OpaquePointer? |
| 129 | 210 |
guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
|