@@ -133,6 +133,13 @@ final class TypeDistributionBin {
|
||
| 133 | 133 |
// in the local archive store, in one schema that can preserve relationships across |
| 134 | 134 |
// data types, sources, devices, workouts, and metadata. |
| 135 | 135 |
|
| 136 |
+// Interface updated 2026-05-18 — see AGENTS.md |
|
| 137 |
+// Services/Protocols/HealthArchiveStore.swift defines the local archive boundary. |
|
| 138 |
+// SQLiteHealthArchiveStore is the current implementation. HealthKit anchored-query |
|
| 139 |
+// pages must be written to this archive before SwiftData UI/cache rows are saved. |
|
| 140 |
+// Deletions are recorded by sampleUUIDHash because HKDeletedObject exposes UUIDs, |
|
| 141 |
+// not complete sample payloads. |
|
| 142 |
+ |
|
| 136 | 143 |
// Interface updated 2026-05-17 — see AGENTS.md |
| 137 | 144 |
// Models/TypeCount.detailCacheData stores precomputed detail data for the current |
| 138 | 145 |
// TypeCount compared with the immediately previous snapshot on the same device. |
@@ -281,11 +281,11 @@ The archive store should expose a small service interface rather than leaking SQ |
||
| 281 | 281 |
|
| 282 | 282 |
```swift |
| 283 | 283 |
protocol HealthArchiveStore {
|
| 284 |
- func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws |
|
| 284 |
+ func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary |
|
| 285 | 285 |
func markVerification(sampleType: HKSampleType, verifiedAt: Date) async throws |
| 286 |
- func recordDisappearance(fingerprint: String, observedMissingAt: Date) async throws |
|
| 287 |
- func records(for reportID: String) async throws -> [ArchivedHealthRecord] |
|
| 288 |
- func exportReport(_ reportID: String) async throws -> URL |
|
| 286 |
+ func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws |
|
| 287 |
+ func records(for request: HealthArchiveRecordRequest) async throws -> [ArchivedHealthRecord] |
|
| 288 |
+ func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL |
|
| 289 | 289 |
} |
| 290 | 290 |
``` |
| 291 | 291 |
|
@@ -298,6 +298,8 @@ Archive rows should preserve: |
||
| 298 | 298 |
- relationship keys for workouts, events, and related samples where available |
| 299 | 299 |
- fingerprints for matching records across HealthProbe, Apple Health XML exports, and backup database extracts |
| 300 | 300 |
|
| 301 |
+The MVP implementation is `SQLiteHealthArchiveStore`, an actor-isolated SQLite archive in Application Support. It is populated from HealthKit anchored-query pages before SwiftData receives derived snapshot/index rows. |
|
| 302 |
+ |
|
| 301 | 303 |
--- |
| 302 | 304 |
|
| 303 | 305 |
## 3. Anomaly Detection Implementation |
@@ -83,6 +83,30 @@ enum HashService {
|
||
| 83 | 83 |
return digest.map { String(format: "%02x", $0) }.joined()
|
| 84 | 84 |
} |
| 85 | 85 |
|
| 86 |
+ static func archiveSemanticFingerprint( |
|
| 87 |
+ typeIdentifier: String, |
|
| 88 |
+ startDate: Date, |
|
| 89 |
+ endDate: Date, |
|
| 90 |
+ value: Double?, |
|
| 91 |
+ unit: String?, |
|
| 92 |
+ categoryValue: Int?, |
|
| 93 |
+ workoutActivityType: UInt?, |
|
| 94 |
+ sourceBundleIdentifier: String? |
|
| 95 |
+ ) -> String {
|
|
| 96 |
+ let input = [ |
|
| 97 |
+ typeIdentifier, |
|
| 98 |
+ iso8601Formatter.string(from: startDate), |
|
| 99 |
+ iso8601Formatter.string(from: endDate), |
|
| 100 |
+ value.map { String(format: "%.12g", $0) } ?? "",
|
|
| 101 |
+ unit ?? "", |
|
| 102 |
+ categoryValue.map(String.init) ?? "", |
|
| 103 |
+ workoutActivityType.map(String.init) ?? "", |
|
| 104 |
+ sourceBundleIdentifier ?? "" |
|
| 105 |
+ ].joined(separator: "|") |
|
| 106 |
+ let digest = SHA256.hash(data: Data(input.utf8)) |
|
| 107 |
+ return digest.map { String(format: "%02x", $0) }.joined()
|
|
| 108 |
+ } |
|
| 109 |
+ |
|
| 86 | 110 |
// Per-snapshot: sort TypeCounts by typeIdentifier, SHA256 of concatenated type hashes. |
| 87 | 111 |
// Filter criterion: quality == .complete; do not use contentHash != "" as a proxy. |
| 88 | 112 |
// A TypeCount with quality = .failed but contentHash = "nonEmpty" must be excluded. |
@@ -38,6 +38,11 @@ extension Array where Element == MonitoredType {
|
||
| 38 | 38 |
final class HealthKitService {
|
| 39 | 39 |
static let shared = HealthKitService() |
| 40 | 40 |
let store = HKHealthStore() |
| 41 |
+ private let archiveStore: HealthArchiveStore |
|
| 42 |
+ |
|
| 43 |
+ private init(archiveStore: HealthArchiveStore = SQLiteHealthArchiveStore.shared) {
|
|
| 44 |
+ self.archiveStore = archiveStore |
|
| 45 |
+ } |
|
| 41 | 46 |
|
| 42 | 47 |
static let allTypes: [MonitoredType] = buildAllTypes() |
| 43 | 48 |
|
@@ -854,6 +859,7 @@ final class HealthKitService {
|
||
| 854 | 859 |
anchor: anchor |
| 855 | 860 |
) |
| 856 | 861 |
} |
| 862 |
+ try await archivePage(page, sampleType: sampleType) |
|
| 857 | 863 |
anchor = page.anchor |
| 858 | 864 |
|
| 859 | 865 |
if page.samples.isEmpty, page.deletedObjects.isEmpty, |
@@ -863,6 +869,7 @@ final class HealthKitService {
|
||
| 863 | 869 |
earliestDate: earliestDate, |
| 864 | 870 |
latestDate: latestDate |
| 865 | 871 |
) {
|
| 872 |
+ try await archiveStore.markVerification(sampleType: sampleType, verifiedAt: Date()) |
|
| 866 | 873 |
progress?.updateBlockProgress( |
| 867 | 874 |
typeIdentifier, |
| 868 | 875 |
detail: "No HealthKit delta", |
@@ -934,6 +941,7 @@ final class HealthKitService {
|
||
| 934 | 941 |
anchor: anchor |
| 935 | 942 |
) |
| 936 | 943 |
} |
| 944 |
+ try await archivePage(page, sampleType: sampleType) |
|
| 937 | 945 |
anchor = page.anchor |
| 938 | 946 |
|
| 939 | 947 |
applyDistributionPage(page, sampleType: sampleType, to: &recordMap) |
@@ -955,6 +963,8 @@ final class HealthKitService {
|
||
| 955 | 963 |
) |
| 956 | 964 |
} |
| 957 | 965 |
|
| 966 |
+ try await archiveStore.markVerification(sampleType: sampleType, verifiedAt: Date()) |
|
| 967 |
+ |
|
| 958 | 968 |
let sortedKeys = recordMap.keys.sorted {
|
| 959 | 969 |
guard let left = recordMap[$0], |
| 960 | 970 |
let right = recordMap[$1] else {
|
@@ -1068,6 +1078,7 @@ final class HealthKitService {
|
||
| 1068 | 1078 |
anchor: anchor |
| 1069 | 1079 |
) |
| 1070 | 1080 |
} |
| 1081 |
+ try await archivePage(page, sampleType: sampleType) |
|
| 1071 | 1082 |
anchor = page.anchor |
| 1072 | 1083 |
|
| 1073 | 1084 |
for sample in page.samples {
|
@@ -1111,6 +1122,8 @@ final class HealthKitService {
|
||
| 1111 | 1122 |
) |
| 1112 | 1123 |
) |
| 1113 | 1124 |
|
| 1125 |
+ try await archiveStore.markVerification(sampleType: sampleType, verifiedAt: Date()) |
|
| 1126 |
+ |
|
| 1114 | 1127 |
guard recordCount > 0 || anchor != nil else {
|
| 1115 | 1128 |
return SampleDistribution( |
| 1116 | 1129 |
totalCount: 0, |
@@ -1253,6 +1266,18 @@ final class HealthKitService {
|
||
| 1253 | 1266 |
} |
| 1254 | 1267 |
} |
| 1255 | 1268 |
|
| 1269 |
+ private func archivePage(_ page: SampleDistributionPage, sampleType: HKSampleType) async throws {
|
|
| 1270 |
+ let observedAt = Date() |
|
| 1271 |
+ _ = try await archiveStore.upsertSamples(page.samples, observedAt: observedAt) |
|
| 1272 |
+ for deletedObject in page.deletedObjects {
|
|
| 1273 |
+ try await archiveStore.recordDisappearance( |
|
| 1274 |
+ sampleUUIDHash: HashService.sampleUUIDHash(deletedObject.uuid.uuidString), |
|
| 1275 |
+ sampleTypeIdentifier: sampleType.identifier, |
|
| 1276 |
+ observedMissingAt: observedAt |
|
| 1277 |
+ ) |
|
| 1278 |
+ } |
|
| 1279 |
+ } |
|
| 1280 |
+ |
|
| 1256 | 1281 |
private static func archiveAnchor(_ anchor: HKQueryAnchor) -> Data? {
|
| 1257 | 1282 |
try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true) |
| 1258 | 1283 |
} |
@@ -5,7 +5,7 @@ import HealthKit |
||
| 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 |
- func recordDisappearance(fingerprint: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws |
|
| 8 |
+ func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws |
|
| 9 | 9 |
func records(for request: HealthArchiveRecordRequest) async throws -> [ArchivedHealthRecord] |
| 10 | 10 |
func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL |
| 11 | 11 |
} |
@@ -28,7 +28,7 @@ struct HealthArchiveReportRequest: Equatable, Sendable {
|
||
| 28 | 28 |
let includedFingerprints: Set<String> |
| 29 | 29 |
} |
| 30 | 30 |
|
| 31 |
-struct ArchivedHealthRecord: Identifiable, Equatable, Sendable {
|
|
| 31 |
+struct ArchivedHealthRecord: Identifiable, Equatable, Sendable, Encodable {
|
|
| 32 | 32 |
let id: String |
| 33 | 33 |
let sampleTypeIdentifier: String |
| 34 | 34 |
let strictFingerprint: String |
@@ -0,0 +1,538 @@ |
||
| 1 |
+import Foundation |
|
| 2 |
+import HealthKit |
|
| 3 |
+import SQLite3 |
|
| 4 |
+ |
|
| 5 |
+private enum SQLiteHealthArchiveStoreError: Error {
|
|
| 6 |
+ case openFailed(String) |
|
| 7 |
+ case prepareFailed(String) |
|
| 8 |
+ case stepFailed(String) |
|
| 9 |
+ case exportEncodingFailed |
|
| 10 |
+} |
|
| 11 |
+ |
|
| 12 |
+// Interface updated 2026-05-18 — see AGENTS.md |
|
| 13 |
+actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
|
| 14 |
+ static let shared = SQLiteHealthArchiveStore() |
|
| 15 |
+ |
|
| 16 |
+ private let databaseURL: URL |
|
| 17 |
+ private var didPrepareSchema = false |
|
| 18 |
+ |
|
| 19 |
+ init(databaseURL: URL? = nil) {
|
|
| 20 |
+ let supportURL = URL.applicationSupportDirectory |
|
| 21 |
+ self.databaseURL = databaseURL ?? supportURL.appending(path: "HealthProbeArchive.sqlite") |
|
| 22 |
+ } |
|
| 23 |
+ |
|
| 24 |
+ func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary {
|
|
| 25 |
+ guard !samples.isEmpty else {
|
|
| 26 |
+ return HealthArchiveWriteSummary(insertedCount: 0, updatedCount: 0, unchangedCount: 0) |
|
| 27 |
+ } |
|
| 28 |
+ |
|
| 29 |
+ let db = try openDatabase() |
|
| 30 |
+ defer { sqlite3_close(db) }
|
|
| 31 |
+ try prepareSchemaIfNeeded(db) |
|
| 32 |
+ try execute("BEGIN IMMEDIATE TRANSACTION", db: db)
|
|
| 33 |
+ do {
|
|
| 34 |
+ let summary = try upsertSamples(samples, observedAt: observedAt, db: db) |
|
| 35 |
+ try execute("COMMIT", db: db)
|
|
| 36 |
+ return summary |
|
| 37 |
+ } catch {
|
|
| 38 |
+ try? execute("ROLLBACK", db: db)
|
|
| 39 |
+ throw error |
|
| 40 |
+ } |
|
| 41 |
+ } |
|
| 42 |
+ |
|
| 43 |
+ func markVerification(sampleType: HKSampleType, verifiedAt: Date) async throws {
|
|
| 44 |
+ let db = try openDatabase() |
|
| 45 |
+ defer { sqlite3_close(db) }
|
|
| 46 |
+ try prepareSchemaIfNeeded(db) |
|
| 47 |
+ |
|
| 48 |
+ let sql = """ |
|
| 49 |
+ UPDATE archive_samples |
|
| 50 |
+ SET last_verified_at = ?, last_seen_at = COALESCE(last_seen_at, ?) |
|
| 51 |
+ WHERE type_identifier = ? AND disappeared_at IS NULL |
|
| 52 |
+ """ |
|
| 53 |
+ try withStatement(sql, db: db) { statement in
|
|
| 54 |
+ sqlite3_bind_double(statement, 1, verifiedAt.timeIntervalSinceReferenceDate) |
|
| 55 |
+ sqlite3_bind_double(statement, 2, verifiedAt.timeIntervalSinceReferenceDate) |
|
| 56 |
+ bindText(sampleType.identifier, to: 3, in: statement) |
|
| 57 |
+ guard sqlite3_step(statement) == SQLITE_DONE else {
|
|
| 58 |
+ throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
|
| 59 |
+ } |
|
| 60 |
+ } |
|
| 61 |
+ } |
|
| 62 |
+ |
|
| 63 |
+ func recordDisappearance(sampleUUIDHash: String, sampleTypeIdentifier: String, observedMissingAt: Date) async throws {
|
|
| 64 |
+ let db = try openDatabase() |
|
| 65 |
+ defer { sqlite3_close(db) }
|
|
| 66 |
+ try prepareSchemaIfNeeded(db) |
|
| 67 |
+ |
|
| 68 |
+ let sql = """ |
|
| 69 |
+ UPDATE archive_samples |
|
| 70 |
+ SET disappeared_at = ?, last_verified_at = ? |
|
| 71 |
+ WHERE sample_uuid_hash = ? AND type_identifier = ? |
|
| 72 |
+ """ |
|
| 73 |
+ try withStatement(sql, db: db) { statement in
|
|
| 74 |
+ sqlite3_bind_double(statement, 1, observedMissingAt.timeIntervalSinceReferenceDate) |
|
| 75 |
+ sqlite3_bind_double(statement, 2, observedMissingAt.timeIntervalSinceReferenceDate) |
|
| 76 |
+ bindText(sampleUUIDHash, to: 3, in: statement) |
|
| 77 |
+ bindText(sampleTypeIdentifier, to: 4, in: statement) |
|
| 78 |
+ guard sqlite3_step(statement) == SQLITE_DONE else {
|
|
| 79 |
+ throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
|
| 80 |
+ } |
|
| 81 |
+ } |
|
| 82 |
+ } |
|
| 83 |
+ |
|
| 84 |
+ func records(for request: HealthArchiveRecordRequest) async throws -> [ArchivedHealthRecord] {
|
|
| 85 |
+ let db = try openDatabase() |
|
| 86 |
+ defer { sqlite3_close(db) }
|
|
| 87 |
+ try prepareSchemaIfNeeded(db) |
|
| 88 |
+ |
|
| 89 |
+ var clauses: [String] = [] |
|
| 90 |
+ if request.sampleTypeIdentifier != nil {
|
|
| 91 |
+ clauses.append("type_identifier = ?")
|
|
| 92 |
+ } |
|
| 93 |
+ if !request.fingerprints.isEmpty {
|
|
| 94 |
+ clauses.append("strict_fingerprint IN (\(Array(repeating: "?", count: request.fingerprints.count).joined(separator: ",")))")
|
|
| 95 |
+ } |
|
| 96 |
+ let whereClause = clauses.isEmpty ? "" : "WHERE \(clauses.joined(separator: " AND "))" |
|
| 97 |
+ let limitClause = request.limit.map { "LIMIT \(max($0, 0))" } ?? ""
|
|
| 98 |
+ let sql = """ |
|
| 99 |
+ SELECT sample_uuid_hash, type_identifier, strict_fingerprint, semantic_fingerprint, |
|
| 100 |
+ start_date, end_date, first_seen_at, last_seen_at, last_verified_at, disappeared_at |
|
| 101 |
+ FROM archive_samples |
|
| 102 |
+ \(whereClause) |
|
| 103 |
+ ORDER BY start_date ASC, strict_fingerprint ASC |
|
| 104 |
+ \(limitClause) |
|
| 105 |
+ """ |
|
| 106 |
+ |
|
| 107 |
+ return try withStatement(sql, db: db) { statement in
|
|
| 108 |
+ var index: Int32 = 1 |
|
| 109 |
+ if let typeIdentifier = request.sampleTypeIdentifier {
|
|
| 110 |
+ bindText(typeIdentifier, to: index, in: statement) |
|
| 111 |
+ index += 1 |
|
| 112 |
+ } |
|
| 113 |
+ for fingerprint in request.fingerprints.sorted() {
|
|
| 114 |
+ bindText(fingerprint, to: index, in: statement) |
|
| 115 |
+ index += 1 |
|
| 116 |
+ } |
|
| 117 |
+ |
|
| 118 |
+ var records: [ArchivedHealthRecord] = [] |
|
| 119 |
+ while sqlite3_step(statement) == SQLITE_ROW {
|
|
| 120 |
+ records.append(ArchivedHealthRecord( |
|
| 121 |
+ id: columnText(statement, 0) ?? "", |
|
| 122 |
+ sampleTypeIdentifier: columnText(statement, 1) ?? "", |
|
| 123 |
+ strictFingerprint: columnText(statement, 2) ?? "", |
|
| 124 |
+ semanticFingerprint: columnText(statement, 3), |
|
| 125 |
+ healthKitUUIDHash: columnText(statement, 0), |
|
| 126 |
+ startDate: columnDate(statement, 4) ?? Date(timeIntervalSinceReferenceDate: 0), |
|
| 127 |
+ endDate: columnDate(statement, 5) ?? Date(timeIntervalSinceReferenceDate: 0), |
|
| 128 |
+ firstSeenAt: columnDate(statement, 6) ?? Date(timeIntervalSinceReferenceDate: 0), |
|
| 129 |
+ lastSeenAt: columnDate(statement, 7), |
|
| 130 |
+ lastVerifiedAt: columnDate(statement, 8), |
|
| 131 |
+ disappearedAt: columnDate(statement, 9) |
|
| 132 |
+ )) |
|
| 133 |
+ } |
|
| 134 |
+ return records |
|
| 135 |
+ } |
|
| 136 |
+ } |
|
| 137 |
+ |
|
| 138 |
+ func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL {
|
|
| 139 |
+ let records = try await records(for: HealthArchiveRecordRequest( |
|
| 140 |
+ sampleTypeIdentifier: nil, |
|
| 141 |
+ fingerprints: request.includedFingerprints, |
|
| 142 |
+ limit: nil |
|
| 143 |
+ )) |
|
| 144 |
+ let payload = HealthArchiveReportPayload( |
|
| 145 |
+ reportID: request.reportID, |
|
| 146 |
+ title: request.title, |
|
| 147 |
+ exportedAt: Date(), |
|
| 148 |
+ records: records |
|
| 149 |
+ ) |
|
| 150 |
+ let data = try JSONEncoder.healthArchive.encode(payload) |
|
| 151 |
+ let exportURL = URL.temporaryDirectory |
|
| 152 |
+ .appending(path: "HealthProbe-\(request.reportID.uuidString)") |
|
| 153 |
+ .appendingPathExtension("json")
|
|
| 154 |
+ try data.write(to: exportURL, options: [.atomic]) |
|
| 155 |
+ return exportURL |
|
| 156 |
+ } |
|
| 157 |
+ |
|
| 158 |
+ private func openDatabase() throws -> OpaquePointer? {
|
|
| 159 |
+ try FileManager.default.createDirectory( |
|
| 160 |
+ at: databaseURL.deletingLastPathComponent(), |
|
| 161 |
+ withIntermediateDirectories: true |
|
| 162 |
+ ) |
|
| 163 |
+ var db: OpaquePointer? |
|
| 164 |
+ guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
|
|
| 165 |
+ let message = db.map(lastErrorMessage) ?? "unable to open archive database" |
|
| 166 |
+ sqlite3_close(db) |
|
| 167 |
+ throw SQLiteHealthArchiveStoreError.openFailed(message) |
|
| 168 |
+ } |
|
| 169 |
+ return db |
|
| 170 |
+ } |
|
| 171 |
+ |
|
| 172 |
+ private func prepareSchemaIfNeeded(_ db: OpaquePointer?) throws {
|
|
| 173 |
+ guard !didPrepareSchema else { return }
|
|
| 174 |
+ try execute("PRAGMA journal_mode = WAL", db: db)
|
|
| 175 |
+ try execute("PRAGMA foreign_keys = ON", db: db)
|
|
| 176 |
+ try execute("""
|
|
| 177 |
+ CREATE TABLE IF NOT EXISTS archive_samples ( |
|
| 178 |
+ sample_uuid_hash TEXT PRIMARY KEY NOT NULL, |
|
| 179 |
+ type_identifier TEXT NOT NULL, |
|
| 180 |
+ strict_fingerprint TEXT NOT NULL, |
|
| 181 |
+ semantic_fingerprint TEXT, |
|
| 182 |
+ start_date REAL NOT NULL, |
|
| 183 |
+ end_date REAL NOT NULL, |
|
| 184 |
+ first_seen_at REAL NOT NULL, |
|
| 185 |
+ last_seen_at REAL, |
|
| 186 |
+ last_verified_at REAL, |
|
| 187 |
+ disappeared_at REAL, |
|
| 188 |
+ observed_count INTEGER NOT NULL DEFAULT 1, |
|
| 189 |
+ value_kind TEXT, |
|
| 190 |
+ value REAL, |
|
| 191 |
+ unit TEXT, |
|
| 192 |
+ category_value INTEGER, |
|
| 193 |
+ workout_activity_type INTEGER, |
|
| 194 |
+ duration_seconds REAL, |
|
| 195 |
+ source_name TEXT, |
|
| 196 |
+ source_bundle_identifier TEXT, |
|
| 197 |
+ source_product_type TEXT, |
|
| 198 |
+ source_version TEXT, |
|
| 199 |
+ source_operating_system_version TEXT, |
|
| 200 |
+ device_name TEXT, |
|
| 201 |
+ device_manufacturer TEXT, |
|
| 202 |
+ device_model TEXT, |
|
| 203 |
+ device_hardware_version TEXT, |
|
| 204 |
+ device_firmware_version TEXT, |
|
| 205 |
+ device_software_version TEXT, |
|
| 206 |
+ device_local_identifier TEXT, |
|
| 207 |
+ device_udi_device_identifier TEXT, |
|
| 208 |
+ metadata_json TEXT, |
|
| 209 |
+ archived_at REAL NOT NULL |
|
| 210 |
+ ) |
|
| 211 |
+ """, db: db) |
|
| 212 |
+ try execute("CREATE INDEX IF NOT EXISTS idx_archive_samples_type_date ON archive_samples(type_identifier, start_date)", db: db)
|
|
| 213 |
+ try execute("CREATE INDEX IF NOT EXISTS idx_archive_samples_strict_fingerprint ON archive_samples(strict_fingerprint)", db: db)
|
|
| 214 |
+ didPrepareSchema = true |
|
| 215 |
+ } |
|
| 216 |
+ |
|
| 217 |
+ private func upsertSamples(_ samples: [HKSample], observedAt: Date, db: OpaquePointer?) throws -> HealthArchiveWriteSummary {
|
|
| 218 |
+ let sql = """ |
|
| 219 |
+ INSERT INTO archive_samples ( |
|
| 220 |
+ sample_uuid_hash, type_identifier, strict_fingerprint, semantic_fingerprint, |
|
| 221 |
+ start_date, end_date, first_seen_at, last_seen_at, last_verified_at, |
|
| 222 |
+ disappeared_at, observed_count, value_kind, value, unit, category_value, |
|
| 223 |
+ workout_activity_type, duration_seconds, source_name, source_bundle_identifier, |
|
| 224 |
+ source_product_type, source_version, source_operating_system_version, |
|
| 225 |
+ device_name, device_manufacturer, device_model, device_hardware_version, |
|
| 226 |
+ device_firmware_version, device_software_version, device_local_identifier, |
|
| 227 |
+ device_udi_device_identifier, metadata_json, archived_at |
|
| 228 |
+ ) VALUES ( |
|
| 229 |
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? |
|
| 230 |
+ ) |
|
| 231 |
+ ON CONFLICT(sample_uuid_hash) DO UPDATE SET |
|
| 232 |
+ strict_fingerprint = excluded.strict_fingerprint, |
|
| 233 |
+ semantic_fingerprint = excluded.semantic_fingerprint, |
|
| 234 |
+ start_date = excluded.start_date, |
|
| 235 |
+ end_date = excluded.end_date, |
|
| 236 |
+ last_seen_at = excluded.last_seen_at, |
|
| 237 |
+ last_verified_at = excluded.last_verified_at, |
|
| 238 |
+ disappeared_at = NULL, |
|
| 239 |
+ observed_count = archive_samples.observed_count + 1, |
|
| 240 |
+ value_kind = excluded.value_kind, |
|
| 241 |
+ value = excluded.value, |
|
| 242 |
+ unit = excluded.unit, |
|
| 243 |
+ category_value = excluded.category_value, |
|
| 244 |
+ workout_activity_type = excluded.workout_activity_type, |
|
| 245 |
+ duration_seconds = excluded.duration_seconds, |
|
| 246 |
+ source_name = excluded.source_name, |
|
| 247 |
+ source_bundle_identifier = excluded.source_bundle_identifier, |
|
| 248 |
+ source_product_type = excluded.source_product_type, |
|
| 249 |
+ source_version = excluded.source_version, |
|
| 250 |
+ source_operating_system_version = excluded.source_operating_system_version, |
|
| 251 |
+ device_name = excluded.device_name, |
|
| 252 |
+ device_manufacturer = excluded.device_manufacturer, |
|
| 253 |
+ device_model = excluded.device_model, |
|
| 254 |
+ device_hardware_version = excluded.device_hardware_version, |
|
| 255 |
+ device_firmware_version = excluded.device_firmware_version, |
|
| 256 |
+ device_software_version = excluded.device_software_version, |
|
| 257 |
+ device_local_identifier = excluded.device_local_identifier, |
|
| 258 |
+ device_udi_device_identifier = excluded.device_udi_device_identifier, |
|
| 259 |
+ metadata_json = excluded.metadata_json, |
|
| 260 |
+ archived_at = excluded.archived_at |
|
| 261 |
+ """ |
|
| 262 |
+ |
|
| 263 |
+ return try withStatement(sql, db: db) { statement in
|
|
| 264 |
+ var inserted = 0 |
|
| 265 |
+ var updated = 0 |
|
| 266 |
+ for sample in samples {
|
|
| 267 |
+ sqlite3_reset(statement) |
|
| 268 |
+ sqlite3_clear_bindings(statement) |
|
| 269 |
+ let row = ArchiveSampleRow(sample: sample, observedAt: observedAt) |
|
| 270 |
+ bind(row, to: statement) |
|
| 271 |
+ guard sqlite3_step(statement) == SQLITE_DONE else {
|
|
| 272 |
+ throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
|
| 273 |
+ } |
|
| 274 |
+ if sqlite3_changes(db) == 1 {
|
|
| 275 |
+ inserted += 1 |
|
| 276 |
+ } else {
|
|
| 277 |
+ updated += 1 |
|
| 278 |
+ } |
|
| 279 |
+ } |
|
| 280 |
+ return HealthArchiveWriteSummary( |
|
| 281 |
+ insertedCount: inserted, |
|
| 282 |
+ updatedCount: updated, |
|
| 283 |
+ unchangedCount: max(0, samples.count - inserted - updated) |
|
| 284 |
+ ) |
|
| 285 |
+ } |
|
| 286 |
+ } |
|
| 287 |
+ |
|
| 288 |
+ private func execute(_ sql: String, db: OpaquePointer?) throws {
|
|
| 289 |
+ guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else {
|
|
| 290 |
+ throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
|
| 291 |
+ } |
|
| 292 |
+ } |
|
| 293 |
+ |
|
| 294 |
+ private func withStatement<T>(_ sql: String, db: OpaquePointer?, body: (OpaquePointer?) throws -> T) throws -> T {
|
|
| 295 |
+ var statement: OpaquePointer? |
|
| 296 |
+ guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
|
|
| 297 |
+ throw SQLiteHealthArchiveStoreError.prepareFailed(lastErrorMessage(db)) |
|
| 298 |
+ } |
|
| 299 |
+ defer { sqlite3_finalize(statement) }
|
|
| 300 |
+ return try body(statement) |
|
| 301 |
+ } |
|
| 302 |
+ |
|
| 303 |
+ private func bind(_ row: ArchiveSampleRow, to statement: OpaquePointer?) {
|
|
| 304 |
+ bindText(row.sampleUUIDHash, to: 1, in: statement) |
|
| 305 |
+ bindText(row.typeIdentifier, to: 2, in: statement) |
|
| 306 |
+ bindText(row.strictFingerprint, to: 3, in: statement) |
|
| 307 |
+ bindText(row.semanticFingerprint, to: 4, in: statement) |
|
| 308 |
+ sqlite3_bind_double(statement, 5, row.startDate.timeIntervalSinceReferenceDate) |
|
| 309 |
+ sqlite3_bind_double(statement, 6, row.endDate.timeIntervalSinceReferenceDate) |
|
| 310 |
+ sqlite3_bind_double(statement, 7, row.observedAt.timeIntervalSinceReferenceDate) |
|
| 311 |
+ sqlite3_bind_double(statement, 8, row.observedAt.timeIntervalSinceReferenceDate) |
|
| 312 |
+ sqlite3_bind_double(statement, 9, row.observedAt.timeIntervalSinceReferenceDate) |
|
| 313 |
+ bindText(row.valueKind, to: 10, in: statement) |
|
| 314 |
+ bindDouble(row.value, to: 11, in: statement) |
|
| 315 |
+ bindText(row.unit, to: 12, in: statement) |
|
| 316 |
+ bindInt(row.categoryValue, to: 13, in: statement) |
|
| 317 |
+ bindInt(row.workoutActivityType, to: 14, in: statement) |
|
| 318 |
+ bindDouble(row.durationSeconds, to: 15, in: statement) |
|
| 319 |
+ bindText(row.sourceName, to: 16, in: statement) |
|
| 320 |
+ bindText(row.sourceBundleIdentifier, to: 17, in: statement) |
|
| 321 |
+ bindText(row.sourceProductType, to: 18, in: statement) |
|
| 322 |
+ bindText(row.sourceVersion, to: 19, in: statement) |
|
| 323 |
+ bindText(row.sourceOperatingSystemVersion, to: 20, in: statement) |
|
| 324 |
+ bindText(row.deviceName, to: 21, in: statement) |
|
| 325 |
+ bindText(row.deviceManufacturer, to: 22, in: statement) |
|
| 326 |
+ bindText(row.deviceModel, to: 23, in: statement) |
|
| 327 |
+ bindText(row.deviceHardwareVersion, to: 24, in: statement) |
|
| 328 |
+ bindText(row.deviceFirmwareVersion, to: 25, in: statement) |
|
| 329 |
+ bindText(row.deviceSoftwareVersion, to: 26, in: statement) |
|
| 330 |
+ bindText(row.deviceLocalIdentifier, to: 27, in: statement) |
|
| 331 |
+ bindText(row.deviceUDI, to: 28, in: statement) |
|
| 332 |
+ bindText(row.metadataJSON, to: 29, in: statement) |
|
| 333 |
+ sqlite3_bind_double(statement, 30, row.observedAt.timeIntervalSinceReferenceDate) |
|
| 334 |
+ } |
|
| 335 |
+} |
|
| 336 |
+ |
|
| 337 |
+private struct ArchiveSampleRow {
|
|
| 338 |
+ let sampleUUIDHash: String |
|
| 339 |
+ let typeIdentifier: String |
|
| 340 |
+ let strictFingerprint: String |
|
| 341 |
+ let semanticFingerprint: String |
|
| 342 |
+ let startDate: Date |
|
| 343 |
+ let endDate: Date |
|
| 344 |
+ let observedAt: Date |
|
| 345 |
+ let valueKind: String? |
|
| 346 |
+ let value: Double? |
|
| 347 |
+ let unit: String? |
|
| 348 |
+ let categoryValue: Int? |
|
| 349 |
+ let workoutActivityType: Int? |
|
| 350 |
+ let durationSeconds: Double? |
|
| 351 |
+ let sourceName: String? |
|
| 352 |
+ let sourceBundleIdentifier: String? |
|
| 353 |
+ let sourceProductType: String? |
|
| 354 |
+ let sourceVersion: String? |
|
| 355 |
+ let sourceOperatingSystemVersion: String? |
|
| 356 |
+ let deviceName: String? |
|
| 357 |
+ let deviceManufacturer: String? |
|
| 358 |
+ let deviceModel: String? |
|
| 359 |
+ let deviceHardwareVersion: String? |
|
| 360 |
+ let deviceFirmwareVersion: String? |
|
| 361 |
+ let deviceSoftwareVersion: String? |
|
| 362 |
+ let deviceLocalIdentifier: String? |
|
| 363 |
+ let deviceUDI: String? |
|
| 364 |
+ let metadataJSON: String? |
|
| 365 |
+ |
|
| 366 |
+ nonisolated init(sample: HKSample, observedAt: Date) {
|
|
| 367 |
+ let sampleUUID = sample.uuid.uuidString |
|
| 368 |
+ let typeIdentifier = sample.sampleType.identifier |
|
| 369 |
+ let quantity = ArchiveSampleRow.quantityPayload(sample) |
|
| 370 |
+ let category = sample as? HKCategorySample |
|
| 371 |
+ let workout = sample as? HKWorkout |
|
| 372 |
+ let sourceRevision = sample.sourceRevision |
|
| 373 |
+ let device = sample.device |
|
| 374 |
+ |
|
| 375 |
+ self.sampleUUIDHash = HashService.sampleUUIDHash(sampleUUID) |
|
| 376 |
+ self.typeIdentifier = typeIdentifier |
|
| 377 |
+ self.strictFingerprint = HashService.sampleFingerprint( |
|
| 378 |
+ typeIdentifier: typeIdentifier, |
|
| 379 |
+ sampleUUID: sampleUUID, |
|
| 380 |
+ startDate: sample.startDate, |
|
| 381 |
+ endDate: sample.endDate |
|
| 382 |
+ ) |
|
| 383 |
+ self.semanticFingerprint = HashService.archiveSemanticFingerprint( |
|
| 384 |
+ typeIdentifier: typeIdentifier, |
|
| 385 |
+ startDate: sample.startDate, |
|
| 386 |
+ endDate: sample.endDate, |
|
| 387 |
+ value: quantity?.value, |
|
| 388 |
+ unit: quantity?.unit, |
|
| 389 |
+ categoryValue: category?.value, |
|
| 390 |
+ workoutActivityType: workout?.workoutActivityType.rawValue, |
|
| 391 |
+ sourceBundleIdentifier: sourceRevision.source.bundleIdentifier |
|
| 392 |
+ ) |
|
| 393 |
+ self.startDate = sample.startDate |
|
| 394 |
+ self.endDate = sample.endDate |
|
| 395 |
+ self.observedAt = observedAt |
|
| 396 |
+ self.valueKind = quantity?.kind ?? (category == nil ? (workout == nil ? nil : "workout") : "category") |
|
| 397 |
+ self.value = quantity?.value |
|
| 398 |
+ self.unit = quantity?.unit |
|
| 399 |
+ self.categoryValue = category?.value |
|
| 400 |
+ self.workoutActivityType = workout.map { Int($0.workoutActivityType.rawValue) }
|
|
| 401 |
+ self.durationSeconds = workout?.duration |
|
| 402 |
+ self.sourceName = sourceRevision.source.name |
|
| 403 |
+ self.sourceBundleIdentifier = sourceRevision.source.bundleIdentifier |
|
| 404 |
+ self.sourceProductType = sourceRevision.productType |
|
| 405 |
+ self.sourceVersion = sourceRevision.version |
|
| 406 |
+ self.sourceOperatingSystemVersion = ArchiveSampleRow.operatingSystemVersionString(sourceRevision.operatingSystemVersion) |
|
| 407 |
+ self.deviceName = device?.name |
|
| 408 |
+ self.deviceManufacturer = device?.manufacturer |
|
| 409 |
+ self.deviceModel = device?.model |
|
| 410 |
+ self.deviceHardwareVersion = device?.hardwareVersion |
|
| 411 |
+ self.deviceFirmwareVersion = device?.firmwareVersion |
|
| 412 |
+ self.deviceSoftwareVersion = device?.softwareVersion |
|
| 413 |
+ self.deviceLocalIdentifier = device?.localIdentifier |
|
| 414 |
+ self.deviceUDI = device?.udiDeviceIdentifier |
|
| 415 |
+ self.metadataJSON = ArchiveSampleRow.metadataJSONString(sample.metadata) |
|
| 416 |
+ } |
|
| 417 |
+ |
|
| 418 |
+ nonisolated private static func quantityPayload(_ sample: HKSample) -> (kind: String, value: Double, unit: String)? {
|
|
| 419 |
+ guard let sample = sample as? HKQuantitySample else { return nil }
|
|
| 420 |
+ let identifier = sample.quantityType.identifier |
|
| 421 |
+ switch identifier {
|
|
| 422 |
+ case HKQuantityTypeIdentifier.heartRate.rawValue, |
|
| 423 |
+ HKQuantityTypeIdentifier.restingHeartRate.rawValue: |
|
| 424 |
+ return ("quantity", sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())), "count/min")
|
|
| 425 |
+ case HKQuantityTypeIdentifier.respiratoryRate.rawValue: |
|
| 426 |
+ return ("quantity", sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())), "count/min")
|
|
| 427 |
+ case HKQuantityTypeIdentifier.activeEnergyBurned.rawValue: |
|
| 428 |
+ return ("quantity", sample.quantity.doubleValue(for: .kilocalorie()), "kcal")
|
|
| 429 |
+ case HKQuantityTypeIdentifier.distanceWalkingRunning.rawValue: |
|
| 430 |
+ return ("quantity", sample.quantity.doubleValue(for: .meter()), "m")
|
|
| 431 |
+ case HKQuantityTypeIdentifier.appleExerciseTime.rawValue: |
|
| 432 |
+ return ("quantity", sample.quantity.doubleValue(for: .minute()), "min")
|
|
| 433 |
+ case HKQuantityTypeIdentifier.environmentalAudioExposure.rawValue, |
|
| 434 |
+ HKQuantityTypeIdentifier.headphoneAudioExposure.rawValue: |
|
| 435 |
+ return ("quantity", sample.quantity.doubleValue(for: .decibelAWeightedSoundPressureLevel()), "dBASPL")
|
|
| 436 |
+ case HKQuantityTypeIdentifier.bodyMass.rawValue: |
|
| 437 |
+ return ("quantity", sample.quantity.doubleValue(for: HKUnit.gramUnit(with: .kilo)), "kg")
|
|
| 438 |
+ case HKQuantityTypeIdentifier.vo2Max.rawValue: |
|
| 439 |
+ let unit = HKUnit.literUnit(with: .milli) |
|
| 440 |
+ .unitDivided(by: HKUnit.gramUnit(with: .kilo)) |
|
| 441 |
+ .unitDivided(by: .minute()) |
|
| 442 |
+ return ("quantity", sample.quantity.doubleValue(for: unit), "mL/kg/min")
|
|
| 443 |
+ default: |
|
| 444 |
+ return ("quantity", sample.quantity.doubleValue(for: .count()), "count")
|
|
| 445 |
+ } |
|
| 446 |
+ } |
|
| 447 |
+ |
|
| 448 |
+ nonisolated private static func metadataJSONString(_ metadata: [String: Any]?) -> String? {
|
|
| 449 |
+ guard let metadata, !metadata.isEmpty else { return nil }
|
|
| 450 |
+ let sanitized = metadata.mapValues(sanitize) |
|
| 451 |
+ guard JSONSerialization.isValidJSONObject(sanitized), |
|
| 452 |
+ let data = try? JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]) else {
|
|
| 453 |
+ return nil |
|
| 454 |
+ } |
|
| 455 |
+ return String(data: data, encoding: .utf8) |
|
| 456 |
+ } |
|
| 457 |
+ |
|
| 458 |
+ nonisolated private static func sanitize(_ value: Any) -> Any {
|
|
| 459 |
+ switch value {
|
|
| 460 |
+ case let value as String: |
|
| 461 |
+ return value |
|
| 462 |
+ case let value as NSNumber: |
|
| 463 |
+ return value |
|
| 464 |
+ case let value as Date: |
|
| 465 |
+ return ISO8601DateFormatter().string(from: value) |
|
| 466 |
+ case let value as UUID: |
|
| 467 |
+ return value.uuidString |
|
| 468 |
+ case let values as [Any]: |
|
| 469 |
+ return values.map(sanitize) |
|
| 470 |
+ case let values as [String: Any]: |
|
| 471 |
+ return values.mapValues(sanitize) |
|
| 472 |
+ default: |
|
| 473 |
+ return String(describing: value) |
|
| 474 |
+ } |
|
| 475 |
+ } |
|
| 476 |
+ |
|
| 477 |
+ nonisolated private static func operatingSystemVersionString(_ version: OperatingSystemVersion) -> String {
|
|
| 478 |
+ "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" |
|
| 479 |
+ } |
|
| 480 |
+} |
|
| 481 |
+ |
|
| 482 |
+nonisolated private struct HealthArchiveReportPayload: Encodable {
|
|
| 483 |
+ let reportID: UUID |
|
| 484 |
+ let title: String |
|
| 485 |
+ let exportedAt: Date |
|
| 486 |
+ let records: [ArchivedHealthRecord] |
|
| 487 |
+} |
|
| 488 |
+ |
|
| 489 |
+nonisolated private extension JSONEncoder {
|
|
| 490 |
+ static var healthArchive: JSONEncoder {
|
|
| 491 |
+ let encoder = JSONEncoder() |
|
| 492 |
+ encoder.dateEncodingStrategy = .iso8601 |
|
| 493 |
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys] |
|
| 494 |
+ return encoder |
|
| 495 |
+ } |
|
| 496 |
+} |
|
| 497 |
+ |
|
| 498 |
+nonisolated private func bindText(_ value: String?, to index: Int32, in statement: OpaquePointer?) {
|
|
| 499 |
+ guard let value else {
|
|
| 500 |
+ sqlite3_bind_null(statement, index) |
|
| 501 |
+ return |
|
| 502 |
+ } |
|
| 503 |
+ sqlite3_bind_text(statement, index, value, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) |
|
| 504 |
+} |
|
| 505 |
+ |
|
| 506 |
+nonisolated private func bindDouble(_ value: Double?, to index: Int32, in statement: OpaquePointer?) {
|
|
| 507 |
+ guard let value else {
|
|
| 508 |
+ sqlite3_bind_null(statement, index) |
|
| 509 |
+ return |
|
| 510 |
+ } |
|
| 511 |
+ sqlite3_bind_double(statement, index, value) |
|
| 512 |
+} |
|
| 513 |
+ |
|
| 514 |
+nonisolated private func bindInt(_ value: Int?, to index: Int32, in statement: OpaquePointer?) {
|
|
| 515 |
+ guard let value else {
|
|
| 516 |
+ sqlite3_bind_null(statement, index) |
|
| 517 |
+ return |
|
| 518 |
+ } |
|
| 519 |
+ sqlite3_bind_int64(statement, index, sqlite3_int64(value)) |
|
| 520 |
+} |
|
| 521 |
+ |
|
| 522 |
+nonisolated private func columnText(_ statement: OpaquePointer?, _ index: Int32) -> String? {
|
|
| 523 |
+ guard sqlite3_column_type(statement, index) != SQLITE_NULL, |
|
| 524 |
+ let pointer = sqlite3_column_text(statement, index) else {
|
|
| 525 |
+ return nil |
|
| 526 |
+ } |
|
| 527 |
+ return String(cString: pointer) |
|
| 528 |
+} |
|
| 529 |
+ |
|
| 530 |
+nonisolated private func columnDate(_ statement: OpaquePointer?, _ index: Int32) -> Date? {
|
|
| 531 |
+ guard sqlite3_column_type(statement, index) != SQLITE_NULL else { return nil }
|
|
| 532 |
+ return Date(timeIntervalSinceReferenceDate: sqlite3_column_double(statement, index)) |
|
| 533 |
+} |
|
| 534 |
+ |
|
| 535 |
+nonisolated private func lastErrorMessage(_ db: OpaquePointer?) -> String {
|
|
| 536 |
+ guard let message = sqlite3_errmsg(db) else { return "unknown SQLite error" }
|
|
| 537 |
+ return String(cString: message) |
|
| 538 |
+} |
|
@@ -95,6 +95,8 @@ HealthProbe's comprehensive snapshot + delta system has been implemented accordi |
||
| 95 | 95 |
- uiCacheConfig: HealthSnapshot, TypeCount, YearlyCount, SnapshotDelta, TypeDelta, AnomalyRecord (derived local UI/index data) |
| 96 | 96 |
- localConfig: OperationLog, DeviceProfile, MetricTimeoutProfile (local-only settings and operation metadata) |
| 97 | 97 |
- Added `HealthArchiveStore` protocol for the single local archive store source of truth |
| 98 |
+- Added `SQLiteHealthArchiveStore`: actor-isolated SQLite archive with WAL, per-sample upsert, disappearance marking, verification timestamps, semantic fingerprints, metadata JSON, and scoped JSON report export |
|
| 99 |
+- HealthKit anchored-query pages now archive samples/deletions before SwiftData snapshot/index rows are built |
|
| 98 | 100 |
- Schema migration recovery: removes legacy SwiftData stores and retries once on failure |
| 99 | 101 |
|
| 100 | 102 |
### UI (Step 13) |
@@ -212,7 +214,7 @@ These tests should be run to ensure all backend functionality is correct: |
||
| 212 | 214 |
|
| 213 | 215 |
1. **Hash** covers only count + date range, not distribution (silentReplacement is best-effort) |
| 214 | 216 |
2. **YearlyCount** precision requires daily bucket granularity (noted if isApproximate) |
| 215 |
-3. **Local archive store implementation is still pending** (protocol boundary exists, SQLite/archive schema still needed) |
|
| 217 |
+3. **Archive query/report UI is still pending** (store exists, UI still mostly reads SwiftData cache) |
|
| 216 | 218 |
4. **No automatic cross-device reconstruction**; cross-device analysis is future macOS/report work |
| 217 | 219 |
|
| 218 | 220 |
## Next Steps |
@@ -221,7 +223,7 @@ These tests should be run to ensure all backend functionality is correct: |
||
| 221 | 223 |
1. Run all 32 verification checks against real HealthKit data |
| 222 | 224 |
2. Create unit tests for delta merge, reason priority, anomaly detection |
| 223 | 225 |
3. Test observer callback debounce with real HKObserverQuery |
| 224 |
-4. Implement the local archive store behind `HealthArchiveStore` |
|
| 226 |
+4. Add archive status/report UI backed by `HealthArchiveStore` |
|
| 225 | 227 |
|
| 226 | 228 |
### Post-MVP |
| 227 | 229 |
1. Integrate actual BGTask expiration guard for observer snapshots (capture partial results) |