@@ -27,7 +27,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 27 | 27 |
| HealthKit capture | Capture now opens one archive observation per user-visible snapshot and attaches HealthKit pages, deleted-object evidence, and type verification to that observation id before finishing it | Continue moving UI/cache reads to archive-backed observation ids | |
| 28 | 28 |
| SQLite archive | Archive v2 schema, snapshot-level observation grouping, 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 | Move Snapshots/Data Types from SwiftData previews to archive/cache DTOs | |
| 29 | 29 |
| Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/diff/health rows, and Dashboard archive-cache status wiring are in place | Move remaining export/report paths to cache DTOs and add targeted partial invalidation | |
| 30 |
-| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration and local device profile settings have moved to Codable stores outside `ModelContainer`. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace remaining operation/local rows, capture review actions, and navigation handles before removing `ModelContainer` | |
|
| 30 |
+| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration, local device profile settings, and operation logging have moved to Codable stores outside `ModelContainer`. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace capture review actions and navigation handles before removing `ModelContainer` | |
|
| 31 | 31 |
| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status prefers archive/cache observation rows and shows cache health; Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; export preview reads the archive export API before showing/exporting JSON; simplified detail mode replaces heavy charts with summary rows on small/accessibility layouts or when enabled in Settings; visible change labels now use neutral new/missing/change-review language, with SwiftData detail cache as transition fallback | Remove remaining SwiftData navigation handles | |
| 32 | 32 |
| Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications | |
| 33 | 33 |
| Export | SQLite export preview, paged JSON writing, SHA256 manifest hashing, and `export_manifests` rows are in place for selected records and observation diffs | Fill remaining recovery-compatible envelope metadata, CSV export, relationship preservation, and reproducibility checks | |
@@ -49,7 +49,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 49 | 49 |
- SwiftData currently blocks iOS 15-era device support. |
| 50 | 50 |
- Existing `Anomaly*` model/service names are legacy language. |
| 51 | 51 |
- Some screens still imply snapshot-count monitoring rather than Time Machine inspection. |
| 52 |
-- Current UI/cache layers still depend on 26 SwiftData-backed files for launch container, remaining operation/local rows, capture review actions, navigation handles, some charts, and PDF paths. |
|
| 52 |
+- Current UI/cache layers still depend on 25 SwiftData-backed files for launch container, capture review actions, navigation handles, some charts, and PDF paths. |
|
| 53 | 53 |
- Snapshots timeline, snapshot detail summary/type rows, Data Types list rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but navigation still uses SwiftData snapshot handles during the transition. |
| 54 | 54 |
- Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated. |
| 55 | 55 |
- Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices. |
@@ -247,8 +247,8 @@ Acceptance: |
||
| 247 | 247 |
Checklist: |
| 248 | 248 |
- [x] Identify all remaining SwiftData imports. |
| 249 | 249 |
- [ ] Replace SwiftData models used by active flows. Metric timeout calibration |
| 250 |
- and local device profile settings have been moved to local Codable stores and |
|
| 251 |
- removed from `ModelContainer`; `OperationLog` and SwiftData |
|
| 250 |
+ local device profile settings, and operation logging have been moved to local |
|
| 251 |
+ Codable stores and removed from `ModelContainer`; SwiftData |
|
| 252 | 252 |
snapshot/navigation handles remain. |
| 253 | 253 |
- [ ] Remove/disable `ModelContainer` as required for target builds. |
| 254 | 254 |
- [x] Add prototype-store ignore/delete/reset path for test installs. |
@@ -9,8 +9,8 @@ local settings stored outside SwiftData where needed. |
||
| 9 | 9 |
|
| 10 | 10 |
## Current Count |
| 11 | 11 |
|
| 12 |
-After moving timeout calibration and local device profile settings out of |
|
| 13 |
-SwiftData, 26 app files still have SwiftData imports. |
|
| 12 |
+After moving timeout calibration, local device profile settings, and operation |
|
| 13 |
+logging out of SwiftData, 25 app files still have SwiftData imports. |
|
| 14 | 14 |
|
| 15 | 15 |
## Launch Container |
| 16 | 16 |
|
@@ -20,8 +20,6 @@ These files keep SwiftData required at app launch: |
||
| 20 | 20 |
- `HealthProbe/ContentView.swift` |
| 21 | 21 |
|
| 22 | 22 |
Retirement path: |
| 23 |
-- move remaining local settings model (`OperationLog`) to non-SwiftData storage |
|
| 24 |
- or Core Data cache/local store; |
|
| 25 | 23 |
- replace prototype snapshot model dependencies in tab roots; |
| 26 | 24 |
- remove `.modelContainer(...)` once no active view needs `@Query` or |
| 27 | 25 |
`ModelContext`. |
@@ -34,7 +32,6 @@ block: |
||
| 34 | 32 |
- `HealthProbe/Models/AnomalyRecord.swift` |
| 35 | 33 |
- `HealthProbe/Models/HealthRecord.swift` |
| 36 | 34 |
- `HealthProbe/Models/HealthSnapshot.swift` |
| 37 |
-- `HealthProbe/Models/OperationLog.swift` |
|
| 38 | 35 |
- `HealthProbe/Models/SnapshotDelta.swift` |
| 39 | 36 |
- `HealthProbe/Models/TypeCount.swift` |
| 40 | 37 |
- `HealthProbe/Models/TypeDelta.swift` |
@@ -46,8 +43,7 @@ Retirement path: |
||
| 46 | 43 |
`YearlyCount`, `TypeDistributionBin`, and `HealthRecord` active reads with |
| 47 | 44 |
archive/cache DTOs; |
| 48 | 45 |
- replace `AnomalyRecord` flows with neutral change/diff DTOs; |
| 49 |
-- move `OperationLog` to a local non-SwiftData store before removing the launch |
|
| 50 |
- container. |
|
| 46 |
+- retire active reads/writes before removing the launch container. |
|
| 51 | 47 |
|
| 52 | 48 |
## Capture And Maintenance Services |
| 53 | 49 |
|
@@ -88,7 +84,7 @@ Retirement path: |
||
| 88 | 84 |
queries plus archive ids; |
| 89 | 85 |
- replace detail navigation parameters from SwiftData models to observation/type |
| 90 | 86 |
DTOs; |
| 91 |
-- remove remaining local-only SwiftData rows; |
|
| 87 |
+- remove remaining snapshot/cache SwiftData rows from active flows; |
|
| 92 | 88 |
- keep paged record drill-down and export paths on archive APIs. |
| 93 | 89 |
|
| 94 | 90 |
## Removed During This Pass |
@@ -107,12 +103,14 @@ The following SwiftData dependencies were removed from active flows: |
||
| 107 | 103 |
- Device display name/color settings now use |
| 108 | 104 |
`HealthProbe/Utilities/LocalDeviceProfile.swift`, a Codable local store used |
| 109 | 105 |
by Settings, Dashboard, Snapshots, and legacy PDF export. |
| 106 |
+- `HealthProbe/Models/OperationLog.swift` was deleted. |
|
| 107 |
+- Snapshot deletion logging now uses |
|
| 108 |
+ `HealthProbe/Utilities/LocalOperationLog.swift`, a bounded Codable local log |
|
| 109 |
+ outside `ModelContainer`. |
|
| 110 | 110 |
|
| 111 | 111 |
## Next Recommended Slices |
| 112 | 112 |
|
| 113 |
-1. Move `OperationLog` away from SwiftData, or delete it if no active reporting |
|
| 114 |
- flow still needs it. |
|
| 115 |
-2. Replace `ContentView` preview/container dependency after tab roots stop using |
|
| 113 |
+1. Replace `ContentView` preview/container dependency after tab roots stop using |
|
| 116 | 114 |
`@Query`. |
| 117 |
-3. Move `DashboardView` capture review actions away from `ModelContext`. |
|
| 118 |
-4. Replace Snapshots/Data Types navigation handles with archive/cache DTOs. |
|
| 115 |
+2. Move `DashboardView` capture review actions away from `ModelContext`. |
|
| 116 |
+3. Replace Snapshots/Data Types navigation handles with archive/cache DTOs. |
|
@@ -21,10 +21,6 @@ struct HealthProbeApp: App {
|
||
| 21 | 21 |
.modelContainer(sharedModelContainer) |
| 22 | 22 |
} |
| 23 | 23 |
|
| 24 |
- // Two local ModelConfiguration instances: |
|
| 25 |
- // uiCacheConfig - derived audit/index data for UI |
|
| 26 |
- // localConfig - local-only settings and operation metadata |
|
| 27 |
- // |
|
| 28 | 24 |
// SwiftData is not the forensic source of truth. Complete HealthKit samples |
| 29 | 25 |
// belong in the local archive store; these rows must remain rebuildable. |
| 30 | 26 |
private static func createModelContainer() throws -> ModelContainer {
|
@@ -33,7 +29,6 @@ struct HealthProbeApp: App {
|
||
| 33 | 29 |
let fullSchema = Schema([ |
| 34 | 30 |
HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, |
| 35 | 31 |
SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self, |
| 36 |
- OperationLog.self, |
|
| 37 | 32 |
]) |
| 38 | 33 |
|
| 39 | 34 |
let appSupportURL = URL.applicationSupportDirectory |
@@ -44,7 +39,6 @@ struct HealthProbeApp: App {
|
||
| 44 | 39 |
HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, |
| 45 | 40 |
SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self, |
| 46 | 41 |
]) |
| 47 |
- let localModels = Schema([OperationLog.self]) |
|
| 48 | 42 |
|
| 49 | 43 |
let uiCacheConfig = ModelConfiguration( |
| 50 | 44 |
"ui-cache", |
@@ -52,15 +46,9 @@ struct HealthProbeApp: App {
|
||
| 52 | 46 |
url: uiCacheStoreURL, |
| 53 | 47 |
cloudKitDatabase: .none |
| 54 | 48 |
) |
| 55 |
- let localConfig = ModelConfiguration( |
|
| 56 |
- "local", |
|
| 57 |
- schema: localModels, |
|
| 58 |
- url: appSupportURL.appending(path: "HealthProbeLocal.store"), |
|
| 59 |
- cloudKitDatabase: .none |
|
| 60 |
- ) |
|
| 61 | 49 |
|
| 62 | 50 |
do {
|
| 63 |
- return try ModelContainer(for: fullSchema, configurations: [uiCacheConfig, localConfig]) |
|
| 51 |
+ return try ModelContainer(for: fullSchema, configurations: [uiCacheConfig]) |
|
| 64 | 52 |
} catch {
|
| 65 | 53 |
let candidates: [URL] = [ |
| 66 | 54 |
uiCacheStoreURL, |
@@ -74,7 +62,7 @@ struct HealthProbeApp: App {
|
||
| 74 | 62 |
appSupportURL.appending(path: "HealthProbeLocal.store.wal"), |
| 75 | 63 |
] |
| 76 | 64 |
for url in candidates { try? FileManager.default.removeItem(at: url) }
|
| 77 |
- return try ModelContainer(for: fullSchema, configurations: [uiCacheConfig, localConfig]) |
|
| 65 |
+ return try ModelContainer(for: fullSchema, configurations: [uiCacheConfig]) |
|
| 78 | 66 |
} |
| 79 | 67 |
} |
| 80 | 68 |
} |
@@ -1,27 +0,0 @@ |
||
| 1 |
-import Foundation |
|
| 2 |
-import SwiftData |
|
| 3 |
- |
|
| 4 |
-@Model final class OperationLog {
|
|
| 5 |
- var id: UUID = UUID() |
|
| 6 |
- var timestamp: Date = Date.now |
|
| 7 |
- var operationType: String = "" |
|
| 8 |
- var affectedSnapshotIDsJSON: String = "[]" |
|
| 9 |
- var summary: String = "" |
|
| 10 |
- var operationDeviceID: String = "" |
|
| 11 |
- var operationAppBuildVersion: String = "" |
|
| 12 |
- |
|
| 13 |
- init(operationType: String, summary: String, deviceID: String, appBuildVersion: String) {
|
|
| 14 |
- self.id = UUID() |
|
| 15 |
- self.operationType = operationType |
|
| 16 |
- self.summary = summary |
|
| 17 |
- self.operationDeviceID = deviceID |
|
| 18 |
- self.operationAppBuildVersion = appBuildVersion |
|
| 19 |
- } |
|
| 20 |
-} |
|
| 21 |
- |
|
| 22 |
-extension OperationLog {
|
|
| 23 |
- var affectedSnapshotIDs: [String] {
|
|
| 24 |
- get { (try? JSONDecoder().decode([String].self, from: Data(affectedSnapshotIDsJSON.utf8))) ?? [] }
|
|
| 25 |
- set { affectedSnapshotIDsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" }
|
|
| 26 |
- } |
|
| 27 |
-} |
|
@@ -67,16 +67,14 @@ enum SnapshotLifecycleService {
|
||
| 67 | 67 |
let deviceID = snapshot.deviceID |
| 68 | 68 |
let version = Bundle.main.appBuildVersion |
| 69 | 69 |
|
| 70 |
- // Build operation log before making changes |
|
| 71 |
- let log = OperationLog( |
|
| 70 |
+ let log = LocalOperationLog( |
|
| 72 | 71 |
operationType: "delete", |
| 73 | 72 |
summary: buildSummary(snapshot: snapshot, incoming: incomingDelta, outgoing: outgoingDelta), |
| 74 | 73 |
deviceID: deviceID, |
| 75 | 74 |
appBuildVersion: version |
| 76 | 75 |
) |
| 77 |
- log.affectedSnapshotIDs = [snapshot.id.uuidString] |
|
| 78 |
- let logID = log.id |
|
| 79 |
- context.insert(log) |
|
| 76 |
+ var completedLog = log |
|
| 77 |
+ completedLog.affectedSnapshotIDs = [snapshot.id.uuidString] |
|
| 80 | 78 |
|
| 81 | 79 |
if incomingDelta == nil && outgoingDelta == nil {
|
| 82 | 80 |
// Standalone snapshot — just delete |
@@ -123,25 +121,8 @@ enum SnapshotLifecycleService {
|
||
| 123 | 121 |
context.delete(snapshot) |
| 124 | 122 |
} |
| 125 | 123 |
|
| 126 |
- // Atomic save: log + destructive changes in same save call |
|
| 127 | 124 |
try context.save() |
| 128 |
- |
|
| 129 |
- // Post-save OperationLog verification |
|
| 130 |
- let verifyDescriptor = FetchDescriptor<OperationLog>( |
|
| 131 |
- predicate: #Predicate<OperationLog> { $0.id == logID }
|
|
| 132 |
- ) |
|
| 133 |
- if (try? context.fetch(verifyDescriptor).first) == nil {
|
|
| 134 |
- logger.critical("OperationLog not found after save — attempting recovery re-insert")
|
|
| 135 |
- let recovery = OperationLog( |
|
| 136 |
- operationType: log.operationType, |
|
| 137 |
- summary: log.summary, |
|
| 138 |
- deviceID: log.operationDeviceID, |
|
| 139 |
- appBuildVersion: log.operationAppBuildVersion |
|
| 140 |
- ) |
|
| 141 |
- recovery.affectedSnapshotIDsJSON = log.affectedSnapshotIDsJSON |
|
| 142 |
- context.insert(recovery) |
|
| 143 |
- try? context.save() |
|
| 144 |
- } |
|
| 125 |
+ LocalOperationLogStore.append(completedLog) |
|
| 145 | 126 |
} |
| 146 | 127 |
|
| 147 | 128 |
static func rebuildMissingDetailCaches( |
@@ -0,0 +1,49 @@ |
||
| 1 |
+import Foundation |
|
| 2 |
+ |
|
| 3 |
+struct LocalOperationLog: Codable, Equatable, Identifiable, Sendable {
|
|
| 4 |
+ var id: UUID = UUID() |
|
| 5 |
+ var timestamp: Date = Date.now |
|
| 6 |
+ var operationType: String |
|
| 7 |
+ var affectedSnapshotIDs: [String] = [] |
|
| 8 |
+ var summary: String |
|
| 9 |
+ var operationDeviceID: String |
|
| 10 |
+ var operationAppBuildVersion: String |
|
| 11 |
+ |
|
| 12 |
+ init(operationType: String, summary: String, deviceID: String, appBuildVersion: String) {
|
|
| 13 |
+ self.operationType = operationType |
|
| 14 |
+ self.summary = summary |
|
| 15 |
+ self.operationDeviceID = deviceID |
|
| 16 |
+ self.operationAppBuildVersion = appBuildVersion |
|
| 17 |
+ } |
|
| 18 |
+} |
|
| 19 |
+ |
|
| 20 |
+enum LocalOperationLogStore {
|
|
| 21 |
+ private static let key = "hp_localOperationLogs" |
|
| 22 |
+ private static let maximumEntries = 200 |
|
| 23 |
+ |
|
| 24 |
+ static func allLogs() -> [LocalOperationLog] {
|
|
| 25 |
+ load().sorted { $0.timestamp > $1.timestamp }
|
|
| 26 |
+ } |
|
| 27 |
+ |
|
| 28 |
+ static func append(_ log: LocalOperationLog) {
|
|
| 29 |
+ var logs = load() |
|
| 30 |
+ logs.append(log) |
|
| 31 |
+ if logs.count > maximumEntries {
|
|
| 32 |
+ logs = Array(logs.suffix(maximumEntries)) |
|
| 33 |
+ } |
|
| 34 |
+ save(logs) |
|
| 35 |
+ } |
|
| 36 |
+ |
|
| 37 |
+ static func removeAll() {
|
|
| 38 |
+ UserDefaults.standard.removeObject(forKey: key) |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ private static func load() -> [LocalOperationLog] {
|
|
| 42 |
+ guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
|
|
| 43 |
+ return (try? JSONDecoder().decode([LocalOperationLog].self, from: data)) ?? [] |
|
| 44 |
+ } |
|
| 45 |
+ |
|
| 46 |
+ private static func save(_ logs: [LocalOperationLog]) {
|
|
| 47 |
+ UserDefaults.standard.set(try? JSONEncoder().encode(logs), forKey: key) |
|
| 48 |
+ } |
|
| 49 |
+} |
|
@@ -0,0 +1,35 @@ |
||
| 1 |
+import XCTest |
|
| 2 |
+@testable import HealthProbe |
|
| 3 |
+ |
|
| 4 |
+final class LocalOperationLogTests: XCTestCase {
|
|
| 5 |
+ override func tearDown() {
|
|
| 6 |
+ LocalOperationLogStore.removeAll() |
|
| 7 |
+ super.tearDown() |
|
| 8 |
+ } |
|
| 9 |
+ |
|
| 10 |
+ func testAppendsLogsNewestFirst() {
|
|
| 11 |
+ var older = LocalOperationLog( |
|
| 12 |
+ operationType: "delete", |
|
| 13 |
+ summary: "Older", |
|
| 14 |
+ deviceID: "device-a", |
|
| 15 |
+ appBuildVersion: "1" |
|
| 16 |
+ ) |
|
| 17 |
+ older.timestamp = Date(timeIntervalSince1970: 1) |
|
| 18 |
+ |
|
| 19 |
+ var newer = LocalOperationLog( |
|
| 20 |
+ operationType: "delete", |
|
| 21 |
+ summary: "Newer", |
|
| 22 |
+ deviceID: "device-a", |
|
| 23 |
+ appBuildVersion: "1" |
|
| 24 |
+ ) |
|
| 25 |
+ newer.timestamp = Date(timeIntervalSince1970: 2) |
|
| 26 |
+ newer.affectedSnapshotIDs = ["snapshot-a"] |
|
| 27 |
+ |
|
| 28 |
+ LocalOperationLogStore.append(older) |
|
| 29 |
+ LocalOperationLogStore.append(newer) |
|
| 30 |
+ |
|
| 31 |
+ let logs = LocalOperationLogStore.allLogs() |
|
| 32 |
+ XCTAssertEqual(logs.map(\.summary), ["Newer", "Older"]) |
|
| 33 |
+ XCTAssertEqual(logs.first?.affectedSnapshotIDs, ["snapshot-a"]) |
|
| 34 |
+ } |
|
| 35 |
+} |
|