@@ -26,7 +26,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 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 | 28 |
| SQLite archive | Archive v2 schema, differential write path, v2 verification/delete bookkeeping, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, formal timing/memory metrics, and XCTest coverage are in place; the legacy `archive_samples` mirror has been removed | Start Core Data cache work | |
| 29 |
-| Core Data cache | Initial programmatic Core Data model and full-cache rebuild service are in place for observation rows, type summaries, daily aggregates, diff summaries, export manifest rows, and archive health | Wire cache reads into UI-facing view models and add targeted partial invalidation | |
|
| 29 |
+| Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/health rows, and Dashboard archive-cache status wiring are in place | Move Snapshots/Data Types to cache DTOs and add targeted partial invalidation | |
|
| 30 | 30 |
| SwiftData cache | Exists | Treat as disposable prototype data; reset/ignore during v2 transition | |
| 31 | 31 |
| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker, and record-change detail uses a separate preview/paged list view with archive-value enrichment and scoped export action | Reframe remaining screens around observations, diffs, export, archive status | |
| 32 | 32 |
| Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications | |
@@ -38,12 +38,11 @@ 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. Wire Core Data cache reads into UI-facing view models. |
|
| 42 |
-2. Replace SwiftData UI dependencies with Core Data/cache DTOs. |
|
| 43 |
-3. Add targeted cache invalidation for affected observation/type ranges. |
|
| 44 |
-4. Update UI language from anomaly/status to observation/diff/export. |
|
| 45 |
-5. Add streaming exports with manifests. |
|
| 46 |
-6. Validate on low-memory/legacy-class devices. |
|
| 41 |
+1. Move Snapshots/Data Types from SwiftData model reads to Core Data/cache DTOs. |
|
| 42 |
+2. Add targeted cache invalidation for affected observation/type ranges. |
|
| 43 |
+3. Update UI language from anomaly/status to observation/diff/export. |
|
| 44 |
+4. Add streaming exports with manifests. |
|
| 45 |
+5. Validate on low-memory/legacy-class devices. |
|
| 47 | 46 |
|
| 48 | 47 |
## Known Prototype Mismatches |
| 49 | 48 |
|
@@ -18,6 +18,53 @@ struct CoreDataArchiveCacheRebuildSummary: Equatable, Sendable {
|
||
| 18 | 18 |
let archiveHealthRows: Int |
| 19 | 19 |
} |
| 20 | 20 |
|
| 21 |
+struct CachedArchiveObservationRow: Equatable, Identifiable, Sendable {
|
|
| 22 |
+ let observationID: Int64 |
|
| 23 |
+ let observedAt: Date |
|
| 24 |
+ let status: String |
|
| 25 |
+ let triggerReason: String |
|
| 26 |
+ let timeZoneIdentifier: String? |
|
| 27 |
+ let trackedTypeCount: Int |
|
| 28 |
+ let visibleRecordCount: Int |
|
| 29 |
+ let appearedCount: Int |
|
| 30 |
+ let disappearedCount: Int |
|
| 31 |
+ let representationChangedCount: Int |
|
| 32 |
+ let archiveSchemaVersion: Int |
|
| 33 |
+ let cacheSchemaVersion: Int |
|
| 34 |
+ let computedAt: Date |
|
| 35 |
+ |
|
| 36 |
+ var id: Int64 { observationID }
|
|
| 37 |
+} |
|
| 38 |
+ |
|
| 39 |
+struct CachedArchiveTypeSummary: Equatable, Identifiable, Sendable {
|
|
| 40 |
+ let observationID: Int64 |
|
| 41 |
+ let sampleTypeIdentifier: String |
|
| 42 |
+ let displayName: String? |
|
| 43 |
+ let visibleRecordCount: Int |
|
| 44 |
+ let appearedCount: Int |
|
| 45 |
+ let disappearedCount: Int |
|
| 46 |
+ let representationChangedCount: Int |
|
| 47 |
+ let earliestStartDate: Date? |
|
| 48 |
+ let latestEndDate: Date? |
|
| 49 |
+ let valueSum: Double? |
|
| 50 |
+ let valueMax: Double? |
|
| 51 |
+ let aggregateHash: String? |
|
| 52 |
+ let computedAt: Date |
|
| 53 |
+ |
|
| 54 |
+ var id: String { "\(observationID)|\(sampleTypeIdentifier)" }
|
|
| 55 |
+} |
|
| 56 |
+ |
|
| 57 |
+struct CachedArchiveHealthStatus: Equatable, Sendable {
|
|
| 58 |
+ let archiveSchemaVersion: Int |
|
| 59 |
+ let cacheSchemaVersion: Int |
|
| 60 |
+ let lastIntegrityCheckAt: Date |
|
| 61 |
+ let lastIntegrityStatus: String |
|
| 62 |
+ let lastErrorKind: String? |
|
| 63 |
+ let lastErrorMessageHash: String? |
|
| 64 |
+ let cacheBuildID: String |
|
| 65 |
+ let computedAt: Date |
|
| 66 |
+} |
|
| 67 |
+ |
|
| 21 | 68 |
// Interface updated 2026-05-24 — see AGENTS.md |
| 22 | 69 |
final class CoreDataArchiveCacheStore {
|
| 23 | 70 |
static let cacheSchemaVersion = 1 |
@@ -94,6 +141,37 @@ final class CoreDataArchiveCacheStore {
|
||
| 94 | 141 |
} |
| 95 | 142 |
} |
| 96 | 143 |
|
| 144 |
+ func observationRows(limit: Int = 50) throws -> [CachedArchiveObservationRow] {
|
|
| 145 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: "CachedObservationRow") |
|
| 146 |
+ request.sortDescriptors = [NSSortDescriptor(key: "observationID", ascending: false)] |
|
| 147 |
+ request.fetchLimit = max(limit, 0) |
|
| 148 |
+ return try container.viewContext.fetch(request).map(Self.observationRow) |
|
| 149 |
+ } |
|
| 150 |
+ |
|
| 151 |
+ func latestObservationRow() throws -> CachedArchiveObservationRow? {
|
|
| 152 |
+ try observationRows(limit: 1).first |
|
| 153 |
+ } |
|
| 154 |
+ |
|
| 155 |
+ func typeSummaries(observationID: Int64, limit: Int? = nil) throws -> [CachedArchiveTypeSummary] {
|
|
| 156 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: "CachedTypeSummary") |
|
| 157 |
+ request.predicate = NSPredicate(format: "observationID == %lld", observationID) |
|
| 158 |
+ request.sortDescriptors = [ |
|
| 159 |
+ NSSortDescriptor(key: "displayName", ascending: true), |
|
| 160 |
+ NSSortDescriptor(key: "sampleTypeIdentifier", ascending: true) |
|
| 161 |
+ ] |
|
| 162 |
+ if let limit {
|
|
| 163 |
+ request.fetchLimit = max(limit, 0) |
|
| 164 |
+ } |
|
| 165 |
+ return try container.viewContext.fetch(request).map(Self.typeSummary) |
|
| 166 |
+ } |
|
| 167 |
+ |
|
| 168 |
+ func latestArchiveHealthStatus() throws -> CachedArchiveHealthStatus? {
|
|
| 169 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: "CachedArchiveHealth") |
|
| 170 |
+ request.sortDescriptors = [NSSortDescriptor(key: "computedAt", ascending: false)] |
|
| 171 |
+ request.fetchLimit = 1 |
|
| 172 |
+ return try container.viewContext.fetch(request).first.map(Self.archiveHealthStatus) |
|
| 173 |
+ } |
|
| 174 |
+ |
|
| 97 | 175 |
private func resetCache(context: NSManagedObjectContext) throws {
|
| 98 | 176 |
for entityName in Self.cacheEntityNames {
|
| 99 | 177 |
let request = NSFetchRequest<NSManagedObject>(entityName: entityName) |
@@ -374,6 +452,57 @@ final class CoreDataArchiveCacheStore {
|
||
| 374 | 452 |
} |
| 375 | 453 |
} |
| 376 | 454 |
|
| 455 |
+private extension CoreDataArchiveCacheStore {
|
|
| 456 |
+ nonisolated static func observationRow(_ object: NSManagedObject) -> CachedArchiveObservationRow {
|
|
| 457 |
+ CachedArchiveObservationRow( |
|
| 458 |
+ observationID: object.value(forKey: "observationID") as? Int64 ?? 0, |
|
| 459 |
+ observedAt: object.value(forKey: "observedAt") as? Date ?? Date(timeIntervalSince1970: 0), |
|
| 460 |
+ status: object.value(forKey: "status") as? String ?? "unknown", |
|
| 461 |
+ triggerReason: object.value(forKey: "triggerReason") as? String ?? "unknown", |
|
| 462 |
+ timeZoneIdentifier: object.value(forKey: "timeZoneIdentifier") as? String, |
|
| 463 |
+ trackedTypeCount: Int(object.value(forKey: "trackedTypeCount") as? Int64 ?? 0), |
|
| 464 |
+ visibleRecordCount: Int(object.value(forKey: "visibleRecordCount") as? Int64 ?? 0), |
|
| 465 |
+ appearedCount: Int(object.value(forKey: "appearedCount") as? Int64 ?? 0), |
|
| 466 |
+ disappearedCount: Int(object.value(forKey: "disappearedCount") as? Int64 ?? 0), |
|
| 467 |
+ representationChangedCount: Int(object.value(forKey: "representationChangedCount") as? Int64 ?? 0), |
|
| 468 |
+ archiveSchemaVersion: Int(object.value(forKey: "archiveSchemaVersion") as? Int64 ?? 0), |
|
| 469 |
+ cacheSchemaVersion: Int(object.value(forKey: "cacheSchemaVersion") as? Int64 ?? 0), |
|
| 470 |
+ computedAt: object.value(forKey: "computedAt") as? Date ?? Date(timeIntervalSince1970: 0) |
|
| 471 |
+ ) |
|
| 472 |
+ } |
|
| 473 |
+ |
|
| 474 |
+ nonisolated static func typeSummary(_ object: NSManagedObject) -> CachedArchiveTypeSummary {
|
|
| 475 |
+ CachedArchiveTypeSummary( |
|
| 476 |
+ observationID: object.value(forKey: "observationID") as? Int64 ?? 0, |
|
| 477 |
+ sampleTypeIdentifier: object.value(forKey: "sampleTypeIdentifier") as? String ?? "", |
|
| 478 |
+ displayName: object.value(forKey: "displayName") as? String, |
|
| 479 |
+ visibleRecordCount: Int(object.value(forKey: "visibleRecordCount") as? Int64 ?? 0), |
|
| 480 |
+ appearedCount: Int(object.value(forKey: "appearedCount") as? Int64 ?? 0), |
|
| 481 |
+ disappearedCount: Int(object.value(forKey: "disappearedCount") as? Int64 ?? 0), |
|
| 482 |
+ representationChangedCount: Int(object.value(forKey: "representationChangedCount") as? Int64 ?? 0), |
|
| 483 |
+ earliestStartDate: object.value(forKey: "earliestStartDate") as? Date, |
|
| 484 |
+ latestEndDate: object.value(forKey: "latestEndDate") as? Date, |
|
| 485 |
+ valueSum: object.value(forKey: "valueSum") as? Double, |
|
| 486 |
+ valueMax: object.value(forKey: "valueMax") as? Double, |
|
| 487 |
+ aggregateHash: object.value(forKey: "aggregateHash") as? String, |
|
| 488 |
+ computedAt: object.value(forKey: "computedAt") as? Date ?? Date(timeIntervalSince1970: 0) |
|
| 489 |
+ ) |
|
| 490 |
+ } |
|
| 491 |
+ |
|
| 492 |
+ nonisolated static func archiveHealthStatus(_ object: NSManagedObject) -> CachedArchiveHealthStatus {
|
|
| 493 |
+ CachedArchiveHealthStatus( |
|
| 494 |
+ archiveSchemaVersion: Int(object.value(forKey: "archiveSchemaVersion") as? Int64 ?? 0), |
|
| 495 |
+ cacheSchemaVersion: Int(object.value(forKey: "cacheSchemaVersion") as? Int64 ?? 0), |
|
| 496 |
+ lastIntegrityCheckAt: object.value(forKey: "lastIntegrityCheckAt") as? Date ?? Date(timeIntervalSince1970: 0), |
|
| 497 |
+ lastIntegrityStatus: object.value(forKey: "lastIntegrityStatus") as? String ?? "unknown", |
|
| 498 |
+ lastErrorKind: object.value(forKey: "lastErrorKind") as? String, |
|
| 499 |
+ lastErrorMessageHash: object.value(forKey: "lastErrorMessageHash") as? String, |
|
| 500 |
+ cacheBuildID: object.value(forKey: "cacheBuildID") as? String ?? "", |
|
| 501 |
+ computedAt: object.value(forKey: "computedAt") as? Date ?? Date(timeIntervalSince1970: 0) |
|
| 502 |
+ ) |
|
| 503 |
+ } |
|
| 504 |
+} |
|
| 505 |
+ |
|
| 377 | 506 |
private extension CoreDataArchiveCacheStore {
|
| 378 | 507 |
static let cacheEntityNames = [ |
| 379 | 508 |
"CachedObservationRow", |
@@ -13,6 +13,7 @@ private enum SQLiteHealthArchiveStoreError: Error {
|
||
| 13 | 13 |
// Interface updated 2026-05-18 — see AGENTS.md |
| 14 | 14 |
actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
| 15 | 15 |
static let shared = SQLiteHealthArchiveStore() |
| 16 |
+ nonisolated static let defaultDatabaseURL = URL.applicationSupportDirectory.appending(path: "HealthProbeArchive.sqlite") |
|
| 16 | 17 |
nonisolated private static let archiveSchemaVersion = 2 |
| 17 | 18 |
nonisolated private static let requiredArchiveV2Tables: [String] = [ |
| 18 | 19 |
"schema_migrations", |
@@ -40,8 +41,7 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 40 | 41 |
private var didPrepareSchema = false |
| 41 | 42 |
|
| 42 | 43 |
init(databaseURL: URL? = nil) {
|
| 43 |
- let supportURL = URL.applicationSupportDirectory |
|
| 44 |
- self.databaseURL = databaseURL ?? supportURL.appending(path: "HealthProbeArchive.sqlite") |
|
| 44 |
+ self.databaseURL = databaseURL ?? Self.defaultDatabaseURL |
|
| 45 | 45 |
} |
| 46 | 46 |
|
| 47 | 47 |
func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary {
|
@@ -23,6 +23,9 @@ final class DashboardViewModel {
|
||
| 23 | 23 |
var completedSnapshotTriggerReason: String? = nil |
| 24 | 24 |
var completedSnapshotRetryOfSnapshotID: UUID? = nil |
| 25 | 25 |
var ambiguousDisappearedMetrics: [AmbiguousDisappearedMetric] = [] |
| 26 |
+ var latestArchiveObservation: CachedArchiveObservationRow? |
|
| 27 |
+ var archiveHealthStatus: CachedArchiveHealthStatus? |
|
| 28 |
+ var archiveCacheError: String? |
|
| 26 | 29 |
|
| 27 | 30 |
private let healthKit = HealthKitService.shared |
| 28 | 31 |
private let diffService = SnapshotDiffService.shared |
@@ -197,6 +200,7 @@ final class DashboardViewModel {
|
||
| 197 | 200 |
} |
| 198 | 201 |
|
| 199 | 202 |
snapshotProgress = .complete |
| 203 |
+ refreshArchiveCache() |
|
| 200 | 204 |
} catch is CancellationError {
|
| 201 | 205 |
snapshotError = "Snapshot creation exceeded the operation timeout. Individual metric timeouts are adaptive; retry failed metrics with an extended timeout when available." |
| 202 | 206 |
snapshotProgress = .idle |
@@ -307,6 +311,7 @@ final class DashboardViewModel {
|
||
| 307 | 311 |
monitoredTypeSetHash: saved.monitoredTypeSetHash, |
| 308 | 312 |
monitoredRegistryVersion: saved.monitoredRegistryVersion |
| 309 | 313 |
) |
| 314 |
+ refreshArchiveCache() |
|
| 310 | 315 |
} catch {
|
| 311 | 316 |
snapshotError = "Failed to save partial snapshot: \(error.localizedDescription)" |
| 312 | 317 |
showProgressSheet = true |
@@ -349,6 +354,19 @@ final class DashboardViewModel {
|
||
| 349 | 354 |
diffService.totalAbsoluteChange(current: latest, baseline: previous) |
| 350 | 355 |
} |
| 351 | 356 |
|
| 357 |
+ func loadArchiveCacheStatus() {
|
|
| 358 |
+ do {
|
|
| 359 |
+ let cache = try CoreDataArchiveCacheStore() |
|
| 360 |
+ latestArchiveObservation = try cache.latestObservationRow() |
|
| 361 |
+ archiveHealthStatus = try cache.latestArchiveHealthStatus() |
|
| 362 |
+ archiveCacheError = nil |
|
| 363 |
+ } catch {
|
|
| 364 |
+ latestArchiveObservation = nil |
|
| 365 |
+ archiveHealthStatus = nil |
|
| 366 |
+ archiveCacheError = error.localizedDescription |
|
| 367 |
+ } |
|
| 368 |
+ } |
|
| 369 |
+ |
|
| 352 | 370 |
private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
|
| 353 | 371 |
try await withThrowingTaskGroup(of: T.self) { group in
|
| 354 | 372 |
group.addTask { try await operation() }
|
@@ -438,6 +456,19 @@ final class DashboardViewModel {
|
||
| 438 | 456 |
fetchProgress = nil |
| 439 | 457 |
showProgressSheet = false |
| 440 | 458 |
snapshotProgress = .idle |
| 459 |
+ refreshArchiveCache() |
|
| 460 |
+ } |
|
| 461 |
+ |
|
| 462 |
+ private func refreshArchiveCache() {
|
|
| 463 |
+ do {
|
|
| 464 |
+ let cache = try CoreDataArchiveCacheStore() |
|
| 465 |
+ _ = try cache.rebuild(fromArchiveAt: SQLiteHealthArchiveStore.defaultDatabaseURL) |
|
| 466 |
+ latestArchiveObservation = try cache.latestObservationRow() |
|
| 467 |
+ archiveHealthStatus = try cache.latestArchiveHealthStatus() |
|
| 468 |
+ archiveCacheError = nil |
|
| 469 |
+ } catch {
|
|
| 470 |
+ archiveCacheError = error.localizedDescription |
|
| 471 |
+ } |
|
| 441 | 472 |
} |
| 442 | 473 |
} |
| 443 | 474 |
|
@@ -62,6 +62,7 @@ struct DashboardView: View {
|
||
| 62 | 62 |
progressSheet |
| 63 | 63 |
} |
| 64 | 64 |
.task {
|
| 65 |
+ viewModel.loadArchiveCacheStatus() |
|
| 65 | 66 |
if !didAutoRequestPermissions && !HealthKitService.shared.hasRequestedPermissionsBefore {
|
| 66 | 67 |
didAutoRequestPermissions = true |
| 67 | 68 |
await viewModel.requestAuthorization() |
@@ -1154,6 +1155,24 @@ struct DashboardView: View {
|
||
| 1154 | 1155 |
.foregroundStyle(.secondary) |
| 1155 | 1156 |
} |
| 1156 | 1157 |
|
| 1158 |
+ if let archiveObservation = viewModel.latestArchiveObservation {
|
|
| 1159 |
+ InfoRow(label: "Archive Observation") {
|
|
| 1160 |
+ Text(archiveObservation.observedAt, style: .relative) |
|
| 1161 |
+ .foregroundStyle(.secondary) |
|
| 1162 |
+ } |
|
| 1163 |
+ InfoRow(label: "Archive Records") {
|
|
| 1164 |
+ Text("\(archiveObservation.visibleRecordCount)")
|
|
| 1165 |
+ .foregroundStyle(.secondary) |
|
| 1166 |
+ } |
|
| 1167 |
+ } |
|
| 1168 |
+ |
|
| 1169 |
+ if let archiveHealthStatus = viewModel.archiveHealthStatus, |
|
| 1170 |
+ archiveHealthStatus.lastIntegrityStatus != "ok" {
|
|
| 1171 |
+ Label("Archive cache integrity: \(archiveHealthStatus.lastIntegrityStatus)", systemImage: "exclamationmark.triangle")
|
|
| 1172 |
+ .font(.caption) |
|
| 1173 |
+ .foregroundStyle(Color.warningAmber) |
|
| 1174 |
+ } |
|
| 1175 |
+ |
|
| 1157 | 1176 |
if let latest, let previous {
|
| 1158 | 1177 |
let delta = viewModel.totalChanges(latest: latest, previous: previous) |
| 1159 | 1178 |
InfoRow(label: "Changes vs Previous") {
|
@@ -56,6 +56,19 @@ final class CoreDataArchiveCacheStoreTests: XCTestCase {
|
||
| 56 | 56 |
) |
| 57 | 57 |
XCTAssertEqual(latestObservation?.value(forKey: "visibleRecordCount") as? Int64, 1) |
| 58 | 58 |
XCTAssertEqual(latestObservation?.value(forKey: "cacheSchemaVersion") as? Int64, Int64(CoreDataArchiveCacheStore.cacheSchemaVersion)) |
| 59 |
+ |
|
| 60 |
+ let latestRow = try XCTUnwrap(cache.latestObservationRow()) |
|
| 61 |
+ XCTAssertEqual(latestRow.observationID, 4) |
|
| 62 |
+ XCTAssertEqual(latestRow.visibleRecordCount, 1) |
|
| 63 |
+ XCTAssertEqual(latestRow.cacheSchemaVersion, CoreDataArchiveCacheStore.cacheSchemaVersion) |
|
| 64 |
+ |
|
| 65 |
+ let summaries = try cache.typeSummaries(observationID: latestRow.observationID) |
|
| 66 |
+ XCTAssertEqual(summaries.count, 1) |
|
| 67 |
+ XCTAssertEqual(summaries.first?.sampleTypeIdentifier, HKQuantityTypeIdentifier.stepCount.rawValue) |
|
| 68 |
+ |
|
| 69 |
+ let health = try XCTUnwrap(cache.latestArchiveHealthStatus()) |
|
| 70 |
+ XCTAssertEqual(health.archiveSchemaVersion, 2) |
|
| 71 |
+ XCTAssertEqual(health.lastIntegrityStatus, "ok") |
|
| 59 | 72 |
} |
| 60 | 73 |
|
| 61 | 74 |
func testDeletingCacheDoesNotDeleteSQLiteArchiveAndRebuildRestoresRows() async throws {
|