Showing 7 changed files with 19 additions and 821 deletions
+3 -3
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -27,9 +27,9 @@ 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 | Continue moving capture/Dashboard actions 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, local device profile settings, operation logging, ContentView preview, Settings data maintenance, legacy detail/PDF views, and legacy anomaly/count-drop review have moved outside SwiftData or been removed. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace capture/review actions 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, operation logging, ContentView preview, Settings data maintenance, legacy detail/PDF views, unused legacy repair/observer services, and legacy anomaly/count-drop review have moved outside SwiftData or been removed. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace capture/review actions before removing `ModelContainer` |
31 31
 | UI | Prototype exists; Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; Snapshots and Data Types tab roots no longer import SwiftData, load Core Data cached observation rows, and open archive/cache-backed detail rows; `SnapshotArchiveDetailView` and `DataTypeArchiveDetailView` read Core Data type/diff summaries and page record drill-down through SQLite; unused legacy SwiftData snapshot/type detail and PDF views have been deleted; record-change evolution and temporal distribution screens now receive DTO rows/cache input instead of querying SwiftData directly; 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 | Move remaining Dashboard capture/review actions away from SwiftData |
32
-| Diff/change explanation | SQL diff summaries, paged diff records, aggregate comparisons, and consolidation-evidence labels exist; legacy anomaly/count-drop review has been removed from active flows; the old direct `HealthSnapshot.typeCounts` diff helper has been retired | Continue moving remaining SwiftData fallback detail paths to archive/cache DTOs |
32
+| Diff/change explanation | SQL diff summaries, paged diff records, aggregate comparisons, and consolidation-evidence labels exist; legacy anomaly/count-drop review has been removed from active flows; the old direct `HealthSnapshot.typeCounts` diff helper has been retired | Keep active diff/count views on archive/cache DTOs |
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 |
34 34
 | Legacy device support | Simplified detail UI mode is implemented for small/accessibility layouts and as a Settings toggle | Remove SwiftData dependency and validate lower deployment targets |
35 35
 | Recovery workflows | Not supported | Preserve export/archive structure for external recovery tools only |
@@ -48,7 +48,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
48 48
 
49 49
 - SwiftData currently blocks iOS 15-era device support.
50 50
 - Some screens still imply snapshot-count monitoring rather than Time Machine inspection.
51
-- Current UI/cache layers still depend on 15 SwiftData-backed files for launch container, capture review actions, model definitions, and legacy repair services.
51
+- Current UI/cache layers still depend on 12 SwiftData-backed files for launch container, capture review actions, legacy delta creation, and model definitions.
52 52
 - Snapshots timeline/detail rows, Data Types list/detail rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist.
53 53
 - Legacy SwiftData-only snapshots are reset for archive v2 test installs rather than migrated.
54 54
 - Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices.
+4 -1
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -234,6 +234,7 @@ Checklist:
234 234
 - [x] Snapshots root reads Core Data cached observation rows directly and no longer imports SwiftData.
235 235
 - [x] Delete unused legacy SwiftData snapshot/type detail views and the PDF
236 236
   exporter tied to those views.
237
+- [x] Delete unused legacy SwiftData lifecycle/observer/repair services.
237 238
 - [x] Data type detail uses Core Data/SQLite `diffSummary` when archive observation ids exist and no longer queries `SnapshotDelta`/`TypeDelta` or rebuilds legacy detail caches from the UI.
238 239
 - [x] Remove unused legacy Type Evolution timeline and direct `HealthSnapshot.typeCounts` diff helper.
239 240
 - [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist.
@@ -264,7 +265,9 @@ Checklist:
264 265
   data maintenance now uses the rebuildable Core Data cache; legacy
265 266
   anomaly/count-drop review has been deleted; Snapshots/Data Types tab roots no
266 267
   longer import SwiftData; unused legacy snapshot/type detail and PDF views have
267
-  been deleted; Dashboard capture/review actions remain.
268
+  been deleted; unused legacy lifecycle/observer/repair services have been
269
+  deleted; Dashboard capture/review actions and capture-time legacy delta
270
+  creation remain.
268 271
 - [ ] Remove/disable `ModelContainer` as required for target builds.
269 272
 - [x] Add prototype-store ignore/delete/reset path for test installs.
270 273
 - [ ] Verify no old-store compatibility layer remains in active flows.
+12 -10
HealthProbe/Doc/04-project/SwiftData-Retirement-Inventory.md
@@ -10,10 +10,9 @@ local settings stored outside SwiftData where needed.
10 10
 ## Current Count
11 11
 
12 12
 After moving the Snapshots and Data Types tab roots to archive/cache
13
-observations and deleting the unused legacy snapshot/type detail views, 15 app
14
-files still have SwiftData imports because capture, Dashboard review actions,
15
-model definitions, and legacy repair services still use prototype snapshot
16
-handles.
13
+observations and deleting unused legacy repair/detail services, 12 app files
14
+still have SwiftData imports because capture, Dashboard review actions, legacy
15
+delta creation, and model definitions still use prototype snapshot handles.
17 16
 
18 17
 ## Launch Container
19 18
 
@@ -50,16 +49,12 @@ These services still write/read legacy SwiftData transition models:
50 49
 
51 50
 - `HealthProbe/Services/DeltaService.swift`
52 51
 - `HealthProbe/Services/HealthKitService.swift`
53
-- `HealthProbe/Services/ObserverService.swift`
54
-- `HealthProbe/Services/SnapshotLifecycleService.swift`
55
-- `HealthProbe/Utilities/TypeCountArchiveRepair.swift`
56 52
 
57 53
 Retirement path:
58 54
 - make capture persist archive observations without writing prototype
59 55
   `HealthSnapshot` bridge rows;
60
-- delete legacy record repair once old SwiftData stores are no longer opened;
61
-- remove snapshot deletion/repair logic after capture and Dashboard actions no
62
-  longer require prototype snapshots.
56
+- replace or remove `DeltaService` once capture no longer writes prototype
57
+  `SnapshotDelta` rows.
63 58
 
64 59
 ## UI And View Models
65 60
 
@@ -129,6 +124,13 @@ The following SwiftData dependencies were removed from active flows:
129 124
   `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift`, and
130 125
   `HealthProbe/Utilities/SnapshotPDFExporter.swift` were deleted. Active
131 126
   snapshot/type drill-down now uses archive/cache DTOs.
127
+- The unused legacy maintenance services
128
+  `HealthProbe/Services/SnapshotLifecycleService.swift`,
129
+  `HealthProbe/Services/IntegrityService.swift`,
130
+  `HealthProbe/Services/ObserverService.swift`, and
131
+  `HealthProbe/Utilities/TypeCountArchiveRepair.swift` were deleted. Active
132
+  observation deletion/repair/background-capture policy now belongs to the
133
+  SQLite archive/cache design, not the old SwiftData chain.
132 134
 - The unused `HealthProbe/Views/DataTypes/TypeEvolutionTimeline.swift` legacy
133 135
   chart was deleted, and the remaining `TypeDiff`/`DiffFilter` DTOs now live in
134 136
   `HealthProbe/Models/TypeDiff.swift` instead of the removed
+0 -79
HealthProbe/Services/IntegrityService.swift
@@ -1,79 +0,0 @@
1
-import Foundation
2
-
3
-enum IntegrityService {
4
-    enum ValidationResult: Equatable {
5
-        case valid
6
-        case checksumMismatch(snapshotID: UUID, expected: String, actual: String)
7
-        case missingDelta(fromID: UUID, toID: UUID)
8
-        case corrupted(snapshotID: UUID, reason: String)
9
-    }
10
-
11
-    // Strict mode: used by chain traversal and analysis.
12
-    // Recomputes checksum from TypeCounts; compares with stored delta.checksumAfter.
13
-    // Returns .valid only if they match exactly.
14
-    static func validate(snapshot: HealthSnapshot, delta: SnapshotDelta?) -> ValidationResult {
15
-        guard let delta else {
16
-            guard snapshot.isChainStart else {
17
-                return .missingDelta(fromID: snapshot.previousSnapshotID ?? UUID(), toID: snapshot.id)
18
-            }
19
-            return .valid
20
-        }
21
-
22
-        let actual = HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? [])
23
-        guard actual == delta.checksumAfter else {
24
-            return .checksumMismatch(snapshotID: snapshot.id, expected: delta.checksumAfter, actual: actual)
25
-        }
26
-        return .valid
27
-    }
28
-
29
-    // Strict chain walk via previousSnapshotID from latest backward.
30
-    // Stops immediately at first missing delta or checksum mismatch — no skips, no auto-repair.
31
-    // FORK DETECTION runs before traversal: if any previousSnapshotID value appears more than
32
-    // once across the snapshot set, returns .corrupted immediately without traversal.
33
-    static func validateChain(snapshots: [HealthSnapshot], deltas: [SnapshotDelta]) -> [ValidationResult] {
34
-        // Fork detection: assert no duplicate previousSnapshotID values
35
-        var seenPrevIDs: [UUID: UUID] = [:] // prevID → first snapshot ID that used it
36
-        for snapshot in snapshots {
37
-            guard let prevID = snapshot.previousSnapshotID else { continue }
38
-            if let existingSnapshotID = seenPrevIDs[prevID] {
39
-                return [.corrupted(
40
-                    snapshotID: existingSnapshotID,
41
-                    reason: "chain fork detected — two snapshots share the same previousSnapshotID"
42
-                )]
43
-            }
44
-            seenPrevIDs[prevID] = snapshot.id
45
-        }
46
-
47
-        let snapshotByID = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.id, $0) })
48
-        let deltaByToID  = Dictionary(uniqueKeysWithValues: deltas.map { ($0.toSnapshotID, $0) })
49
-
50
-        // Walk from the latest snapshot backward
51
-        guard let latest = snapshots.max(by: { $0.localSequenceNumber < $1.localSequenceNumber }) else {
52
-            return []
53
-        }
54
-
55
-        var results: [ValidationResult] = []
56
-        var current: HealthSnapshot? = latest
57
-
58
-        while let node = current {
59
-            let delta = deltaByToID[node.id]
60
-            let result = validate(snapshot: node, delta: delta)
61
-
62
-            switch result {
63
-            case .valid:
64
-                break
65
-            case .checksumMismatch, .missingDelta, .corrupted:
66
-                // Strict mode — stop immediately on any error
67
-                results.append(result)
68
-                return results
69
-            }
70
-
71
-            if node.isChainStart {
72
-                break
73
-            }
74
-            current = node.previousSnapshotID.flatMap { snapshotByID[$0] }
75
-        }
76
-
77
-        return results
78
-    }
79
-}
+0 -129
HealthProbe/Services/ObserverService.swift
@@ -1,129 +0,0 @@
1
-import Foundation
2
-import HealthKit
3
-import SwiftData
4
-import os.log
5
-
6
-private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "ObserverService")
7
-
8
-// Without background observation, a HealthKit deletion followed by reinsertion between two
9
-// manual snapshots is completely invisible. HKObserverQuery with background delivery closes this gap.
10
-// Note: HKObserverQuery signals that something changed but does not identify what changed.
11
-// Actual detection still comes from the next full snapshot + delta comparison.
12
-final class ObserverService {
13
-    static let shared = ObserverService()
14
-
15
-    // Minimum interval between observer-triggered snapshots — manual snapshots bypass this entirely.
16
-    private static let debounceIntervalSeconds: TimeInterval = 600  // 10 minutes
17
-
18
-    private var observerQueries: [HKObserverQuery] = []
19
-    private var debounceTask: Task<Void, Never>?
20
-    private var lastCallbackTimestamp: Date?
21
-    private var accumulatedTypeIDs: Set<String> = []
22
-    private let lock = NSLock()
23
-
24
-    private weak var modelContainer: ModelContainer?
25
-    private var selectedTypeIDs: Set<String> = []
26
-
27
-    func startObserving(types: [HKObjectType], store: HKHealthStore, container: ModelContainer, selectedTypeIDs: Set<String>) {
28
-        self.modelContainer = container
29
-        self.selectedTypeIDs = selectedTypeIDs
30
-
31
-        for objectType in types {
32
-            let query = HKObserverQuery(sampleType: objectType as! HKSampleType, predicate: nil) { [weak self] _, completionHandler, error in
33
-                // Always call first — HealthKit re-fires indefinitely if not called
34
-                defer { completionHandler() }
35
-                // Schedule snapshot task separately; failure is logged, not fatal
36
-                if let error {
37
-                    logger.error("ObserverQuery error for \(objectType.identifier): \(error)")
38
-                    return
39
-                }
40
-                self?.handleObserverCallback(typeID: objectType.identifier)
41
-            }
42
-            store.execute(query)
43
-
44
-            // Frequency: .immediate for critical types, .daily for others
45
-            let frequency: HKUpdateFrequency = isCriticalType(objectType.identifier) ? .immediate : .daily
46
-            store.enableBackgroundDelivery(for: objectType, frequency: frequency) { success, error in
47
-                if !success {
48
-                    logger.error("Failed to enable background delivery for \(objectType.identifier): \(String(describing: error))")
49
-                }
50
-            }
51
-            observerQueries.append(query)
52
-        }
53
-    }
54
-
55
-    // MARK: - Callback handling
56
-
57
-    private func handleObserverCallback(typeID: String) {
58
-        let alreadyScheduled = lock.withLock {
59
-            let now = Date()
60
-            lastCallbackTimestamp = now
61
-            accumulatedTypeIDs.insert(typeID)
62
-            return debounceTask != nil
63
-        }
64
-
65
-        guard !alreadyScheduled else { return }
66
-
67
-        debounceTask = Task { [weak self] in
68
-            guard let self else { return }
69
-            // Wait out the debounce window
70
-            try? await Task.sleep(nanoseconds: UInt64(Self.debounceIntervalSeconds * 1_000_000_000))
71
-            await self.tryCreateObserverSnapshot()
72
-        }
73
-    }
74
-
75
-    @MainActor
76
-    private func tryCreateObserverSnapshot() async {
77
-        lock.withLock {
78
-            debounceTask = nil
79
-        }
80
-
81
-        guard let container = modelContainer else {
82
-            logger.error("ObserverService: no modelContainer — cannot create snapshot")
83
-            return
84
-        }
85
-
86
-        // Manual overlap suppression: if a manual snapshot was created during the debounce window,
87
-        // cancel the observer snapshot to avoid a redundant .unchanged delta.
88
-        let context = ModelContext(container)
89
-        if let lastCallback = lastCallbackTimestamp {
90
-            let descriptor = FetchDescriptor<HealthSnapshot>(
91
-                sortBy: [SortDescriptor(\.timestamp, order: .reverse)]
92
-            )
93
-            let recent = try? context.fetch(descriptor)
94
-            if let latestManual = recent?.first(where: { $0.triggerReason == "manual" }),
95
-               latestManual.timestamp > lastCallback {
96
-                logger.info("ObserverService: suppressed — manual snapshot captured during debounce window")
97
-                return
98
-            }
99
-        }
100
-
101
-        // Create one consolidated snapshot covering all monitored types
102
-        do {
103
-            let snapshot = try await HealthKitService.shared.createSnapshot(
104
-                in: context,
105
-                selectedTypeIDs: selectedTypeIDs,
106
-                adaptiveTimeoutsEnabled: true,
107
-                triggerReason: "observerCallback"
108
-            )
109
-            logger.info("ObserverService: observer-triggered snapshot created \(snapshot.id)")
110
-        } catch {
111
-            logger.error("ObserverService: failed to create snapshot — \(error)")
112
-        }
113
-
114
-        lock.withLock {
115
-            accumulatedTypeIDs.removeAll()
116
-            lastCallbackTimestamp = nil
117
-        }
118
-    }
119
-
120
-    // MARK: - Type classification
121
-
122
-    private func isCriticalType(_ typeID: String) -> Bool {
123
-        let critical: Set<String> = Set([
124
-            HKQuantityType.quantityType(forIdentifier: .heartRate)?.identifier,
125
-            HKQuantityType.quantityType(forIdentifier: .stepCount)?.identifier,
126
-        ].compactMap { $0 })
127
-        return critical.contains(typeID)
128
-    }
129
-}
+0 -431
HealthProbe/Services/SnapshotLifecycleService.swift
@@ -1,431 +0,0 @@
1
-import Foundation
2
-import SwiftData
3
-import os.log
4
-
5
-private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "SnapshotLifecycleService")
6
-
7
-enum SnapshotLifecycleService {
8
-    struct DeletionPreview {
9
-        let target: HealthSnapshot
10
-        let affectedDeltas: [SnapshotDelta]
11
-        let mergedDelta: SnapshotDelta?
12
-        let willBreakChain: Bool
13
-        let description: String
14
-    }
15
-
16
-    static func previewDeletion(of snapshot: HealthSnapshot, context: ModelContext) throws -> DeletionPreview {
17
-        let incomingDelta = try fetchIncomingDelta(toSnapshotID: snapshot.id, context: context)
18
-        let outgoingDelta = try fetchOutgoingDelta(fromSnapshotID: snapshot.id, context: context)
19
-
20
-        var willBreakChain = false
21
-        var description = ""
22
-
23
-        let integrityResult = IntegrityService.validate(snapshot: snapshot, delta: incomingDelta)
24
-        switch integrityResult {
25
-        case .valid:
26
-            break
27
-        case .checksumMismatch(_, let expected, let actual):
28
-            willBreakChain = true
29
-            description = "Checksum mismatch: expected \(expected.prefix(8))…, got \(actual.prefix(8))…"
30
-        case .missingDelta(let fromID, _):
31
-            willBreakChain = true
32
-            description = "Missing delta from \(fromID)"
33
-        case .corrupted(_, let reason):
34
-            willBreakChain = true
35
-            description = reason
36
-        }
37
-
38
-        var affectedDeltas: [SnapshotDelta] = []
39
-        if let d = incomingDelta { affectedDeltas.append(d) }
40
-        if let d = outgoingDelta { affectedDeltas.append(d) }
41
-
42
-        // For intermediate deletion, compute the merged delta preview
43
-        var mergedDelta: SnapshotDelta? = nil
44
-        if let d1 = incomingDelta, let d2 = outgoingDelta,
45
-           let prevSnap = try fetchSnapshot(id: d1.fromSnapshotID, context: context),
46
-           let nextSnap = try fetchSnapshot(id: d2.toSnapshotID, context: context) {
47
-            mergedDelta = DeltaService.mergeDeltas(
48
-                d1: d1, d2: d2,
49
-                snapshotBefore: prevSnap,
50
-                snapshotAfter: nextSnap
51
-            )
52
-        }
53
-
54
-        return DeletionPreview(
55
-            target: snapshot,
56
-            affectedDeltas: affectedDeltas,
57
-            mergedDelta: mergedDelta,
58
-            willBreakChain: willBreakChain,
59
-            description: description
60
-        )
61
-    }
62
-
63
-    static func delete(_ snapshot: HealthSnapshot, context: ModelContext) throws {
64
-        let incomingDelta = try fetchIncomingDelta(toSnapshotID: snapshot.id, context: context)
65
-        let outgoingDelta = try fetchOutgoingDelta(fromSnapshotID: snapshot.id, context: context)
66
-
67
-        let deviceID = snapshot.deviceID
68
-        let version = Bundle.main.appBuildVersion
69
-
70
-        let log = LocalOperationLog(
71
-            operationType: "delete",
72
-            summary: buildSummary(snapshot: snapshot, incoming: incomingDelta, outgoing: outgoingDelta),
73
-            deviceID: deviceID,
74
-            appBuildVersion: version
75
-        )
76
-        var completedLog = log
77
-        completedLog.affectedSnapshotIDs = [snapshot.id.uuidString]
78
-
79
-        if incomingDelta == nil && outgoingDelta == nil {
80
-            // Standalone snapshot — just delete
81
-            context.delete(snapshot)
82
-        } else if incomingDelta == nil, let outgoing = outgoingDelta {
83
-            // Oldest snapshot: delete it and outgoing delta, set next as chain start
84
-            if let nextSnap = try fetchSnapshot(id: outgoing.toSnapshotID, context: context) {
85
-                nextSnap.previousSnapshotID = nil
86
-                nextSnap.isChainStart = true
87
-                nextSnap.contentEquivalentSnapshotID = nil
88
-                for typeCount in nextSnap.typeCounts ?? [] {
89
-                    typeCount.contentEquivalentTypeCountID = nil
90
-                }
91
-                invalidateDetailCaches(for: nextSnap)
92
-            }
93
-            context.delete(outgoing)
94
-            context.delete(snapshot)
95
-        } else if outgoingDelta == nil, let incoming = incomingDelta {
96
-            // Latest snapshot: delete it and incoming delta
97
-            context.delete(incoming)
98
-            context.delete(snapshot)
99
-        } else if let d1 = incomingDelta, let d2 = outgoingDelta {
100
-            // Intermediate snapshot: merge deltas and delete
101
-            guard let prevSnap = try fetchSnapshot(id: d1.fromSnapshotID, context: context),
102
-                  let nextSnap = try fetchSnapshot(id: d2.toSnapshotID, context: context) else {
103
-                logger.error("SnapshotLifecycleService: failed to find surrounding snapshots for merge")
104
-                throw LifecycleError.missingNeighbor
105
-            }
106
-
107
-            let merged = DeltaService.mergeDeltas(
108
-                d1: d1, d2: d2,
109
-                snapshotBefore: prevSnap,
110
-                snapshotAfter: nextSnap
111
-            )
112
-            merged.deviceID = deviceID
113
-            context.insert(merged)
114
-            for td in merged.typeDeltas ?? [] { context.insert(td) }
115
-
116
-            nextSnap.previousSnapshotID = prevSnap.id
117
-            _ = refreshContentEquivalence(for: nextSnap, baseline: prevSnap)
118
-            invalidateDetailCaches(for: nextSnap)
119
-            context.delete(d1)
120
-            context.delete(d2)
121
-            context.delete(snapshot)
122
-        }
123
-
124
-        try context.save()
125
-        LocalOperationLogStore.append(completedLog)
126
-    }
127
-
128
-    static func rebuildMissingDetailCaches(
129
-        context: ModelContext,
130
-        maxTypeCounts: Int
131
-    ) throws -> Bool {
132
-        guard maxTypeCounts > 0 else { return false }
133
-        MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.begin", metadata: [
134
-            "maxTypeCounts": "\(maxTypeCounts)"
135
-        ])
136
-
137
-        let snapshotIDs = try context.fetch(FetchDescriptor<HealthSnapshot>()).map { $0.id }
138
-        MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.snapshotsFetched", metadata: [
139
-            "snapshotCount": "\(snapshotIDs.count)"
140
-        ])
141
-        var rebuiltCount = 0
142
-        var updatedAliases = 0
143
-
144
-        // Alias pass: process in batches to avoid memory bloat
145
-        let aliasBatchSize = 5
146
-        for batchStart in stride(from: 0, to: snapshotIDs.count, by: aliasBatchSize) {
147
-            let batchEnd = min(batchStart + aliasBatchSize, snapshotIDs.count)
148
-            for id in snapshotIDs[batchStart..<batchEnd] {
149
-                guard let snapshot = try fetchSnapshot(id: id, context: context),
150
-                      let baselineID = snapshot.previousSnapshotID,
151
-                      let baseline = try fetchSnapshot(id: baselineID, context: context) else {
152
-                    continue
153
-                }
154
-
155
-                if refreshContentEquivalence(for: snapshot, baseline: baseline) {
156
-                    updatedAliases += 1
157
-                }
158
-            }
159
-            // Save batch to flush object cache and allow garbage collection
160
-            if updatedAliases > 0 {
161
-                try context.save()
162
-            }
163
-        }
164
-
165
-        MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.aliasPassFinished", metadata: [
166
-            "updatedAliases": "\(updatedAliases)"
167
-        ])
168
-        if MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) {
169
-            MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.pausedMemoryPressure", metadata: [
170
-                "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit),
171
-                "phase": "afterAliasPass",
172
-                "updatedAliases": "\(updatedAliases)"
173
-            ])
174
-            return false
175
-        }
176
-
177
-        // Detail cache pass
178
-        for id in snapshotIDs {
179
-            guard let snapshot = try fetchSnapshot(id: id, context: context),
180
-                  let baselineID = snapshot.previousSnapshotID,
181
-                  let baseline = try fetchSnapshot(id: baselineID, context: context) else {
182
-                continue
183
-            }
184
-
185
-            let baselineByType = Dictionary(
186
-                uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
187
-            )
188
-
189
-            if snapshot.isContentAlias,
190
-               snapshot.contentEquivalentSnapshotID == baseline.contentRepresentativeSnapshotID {
191
-                continue
192
-            }
193
-
194
-            for typeCount in snapshot.typeCounts ?? [] {
195
-                guard shouldBackfillDetailCache(
196
-                    typeCount: typeCount,
197
-                    baseline: baselineByType[typeCount.typeIdentifier],
198
-                    baselineID: baseline.id
199
-                ) else {
200
-                    continue
201
-                }
202
-
203
-                if MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) {
204
-                    MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.pausedMemoryPressure", metadata: [
205
-                        "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit),
206
-                        "phase": "beforeDetailCacheBuild",
207
-                        "rebuiltCount": "\(rebuiltCount)",
208
-                        "updatedAliases": "\(updatedAliases)"
209
-                    ])
210
-                    if rebuiltCount > 0 || updatedAliases > 0 {
211
-                        try context.save()
212
-                    }
213
-                    return false
214
-                }
215
-
216
-                MemoryLog.log("snapshotLifecycle.detailCache.buildBegin", metadata: detailCacheMetadata(
217
-                    current: typeCount,
218
-                    previous: baselineByType[typeCount.typeIdentifier],
219
-                    source: "backfill"
220
-                ))
221
-                typeCount.setDetailCache(
222
-                    TypeCountDetailCacheBuilder.build(
223
-                        current: typeCount,
224
-                        previous: baselineByType[typeCount.typeIdentifier],
225
-                        baselineSnapshotID: baseline.id
226
-                    )
227
-                )
228
-                MemoryLog.log("snapshotLifecycle.detailCache.buildEnd", metadata: detailCacheMetadata(
229
-                    current: typeCount,
230
-                    previous: baselineByType[typeCount.typeIdentifier],
231
-                    source: "backfill"
232
-                ))
233
-                rebuiltCount += 1
234
-
235
-                if rebuiltCount >= maxTypeCounts {
236
-                    MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.pausedAtLimit", metadata: [
237
-                        "rebuiltCount": "\(rebuiltCount)",
238
-                        "updatedAliases": "\(updatedAliases)"
239
-                    ])
240
-                    try context.save()
241
-                    return false
242
-                }
243
-            }
244
-        }
245
-
246
-        if rebuiltCount > 0 || updatedAliases > 0 {
247
-            try context.save()
248
-        }
249
-        MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.complete", metadata: [
250
-            "rebuiltCount": "\(rebuiltCount)",
251
-            "updatedAliases": "\(updatedAliases)"
252
-        ])
253
-        return true
254
-    }
255
-
256
-    // MARK: - Fetch helpers
257
-
258
-    private static func fetchIncomingDelta(toSnapshotID snapshotID: UUID, context: ModelContext) throws -> SnapshotDelta? {
259
-        let descriptor = FetchDescriptor<SnapshotDelta>(
260
-            predicate: #Predicate<SnapshotDelta> { $0.toSnapshotID == snapshotID }
261
-        )
262
-        return try context.fetch(descriptor).first
263
-    }
264
-
265
-    private static func fetchOutgoingDelta(fromSnapshotID snapshotID: UUID, context: ModelContext) throws -> SnapshotDelta? {
266
-        let descriptor = FetchDescriptor<SnapshotDelta>(
267
-            predicate: #Predicate<SnapshotDelta> { $0.fromSnapshotID == snapshotID }
268
-        )
269
-        return try context.fetch(descriptor).first
270
-    }
271
-
272
-    private static func fetchSnapshot(id: UUID, context: ModelContext) throws -> HealthSnapshot? {
273
-        let descriptor = FetchDescriptor<HealthSnapshot>(
274
-            predicate: #Predicate<HealthSnapshot> { $0.id == id }
275
-        )
276
-        return try context.fetch(descriptor).first
277
-    }
278
-
279
-    private static func invalidateDetailCaches(for snapshot: HealthSnapshot) {
280
-        for typeCount in snapshot.typeCounts ?? [] {
281
-            typeCount.setDetailCache(nil)
282
-        }
283
-    }
284
-
285
-    @discardableResult
286
-    private static func refreshContentEquivalence(for snapshot: HealthSnapshot, baseline: HealthSnapshot) -> Bool {
287
-        let previousSnapshotAliasID = snapshot.contentEquivalentSnapshotID
288
-        let previousTypeAliasIDs = Dictionary(
289
-            uniqueKeysWithValues: (snapshot.typeCounts ?? []).map { ($0.id, $0.contentEquivalentTypeCountID) }
290
-        )
291
-
292
-        snapshot.contentEquivalentSnapshotID = nil
293
-        for typeCount in snapshot.typeCounts ?? [] {
294
-            typeCount.contentEquivalentTypeCountID = nil
295
-        }
296
-
297
-        guard snapshot.monitoredTypeSetHash == baseline.monitoredTypeSetHash else {
298
-            return contentEquivalenceDidChange(
299
-                snapshot: snapshot,
300
-                previousSnapshotAliasID: previousSnapshotAliasID,
301
-                previousTypeAliasIDs: previousTypeAliasIDs
302
-            )
303
-        }
304
-
305
-        let baselineByType = Dictionary(
306
-            uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
307
-        )
308
-
309
-        for typeCount in snapshot.typeCounts ?? [] {
310
-            guard let baselineType = baselineByType[typeCount.typeIdentifier],
311
-                  areTypeCountsContentEquivalent(typeCount, baselineType) else {
312
-                continue
313
-            }
314
-
315
-            typeCount.contentEquivalentTypeCountID = baselineType.contentRepresentativeTypeCountID
316
-            typeCount.setDetailCache(nil)
317
-        }
318
-
319
-        if areTypeCountsContentEquivalent(snapshot.typeCounts ?? [], baseline.typeCounts ?? []) {
320
-            snapshot.contentEquivalentSnapshotID = baseline.contentRepresentativeSnapshotID
321
-        }
322
-
323
-        return contentEquivalenceDidChange(
324
-            snapshot: snapshot,
325
-            previousSnapshotAliasID: previousSnapshotAliasID,
326
-            previousTypeAliasIDs: previousTypeAliasIDs
327
-        )
328
-    }
329
-
330
-    private static func contentEquivalenceDidChange(
331
-        snapshot: HealthSnapshot,
332
-        previousSnapshotAliasID: UUID?,
333
-        previousTypeAliasIDs: [UUID: UUID?]
334
-    ) -> Bool {
335
-        if snapshot.contentEquivalentSnapshotID != previousSnapshotAliasID {
336
-            return true
337
-        }
338
-
339
-        for typeCount in snapshot.typeCounts ?? [] {
340
-            if typeCount.contentEquivalentTypeCountID != (previousTypeAliasIDs[typeCount.id] ?? nil) {
341
-                return true
342
-            }
343
-        }
344
-
345
-        return false
346
-    }
347
-
348
-    private static func areTypeCountsContentEquivalent(_ lhs: [TypeCount], _ rhs: [TypeCount]) -> Bool {
349
-        let lhsByType = Dictionary(uniqueKeysWithValues: lhs.map { ($0.typeIdentifier, $0) })
350
-        let rhsByType = Dictionary(uniqueKeysWithValues: rhs.map { ($0.typeIdentifier, $0) })
351
-        guard lhsByType.keys == rhsByType.keys else { return false }
352
-
353
-        for typeIdentifier in lhsByType.keys {
354
-            guard let lhsType = lhsByType[typeIdentifier],
355
-                  let rhsType = rhsByType[typeIdentifier],
356
-                  lhsType.count == rhsType.count,
357
-                  lhsType.contentHash == rhsType.contentHash,
358
-                  lhsType.quality == rhsType.quality,
359
-                  lhsType.isUnsupported == rhsType.isUnsupported else {
360
-                return false
361
-            }
362
-        }
363
-
364
-        return true
365
-    }
366
-
367
-    private static func areTypeCountsContentEquivalent(_ lhs: TypeCount, _ rhs: TypeCount) -> Bool {
368
-        lhs.count == rhs.count &&
369
-        lhs.contentHash == rhs.contentHash &&
370
-        lhs.quality == rhs.quality &&
371
-        lhs.isUnsupported == rhs.isUnsupported
372
-    }
373
-
374
-    @MainActor private static func shouldBackfillDetailCache(
375
-        typeCount: TypeCount,
376
-        baseline: TypeCount?,
377
-        baselineID: UUID
378
-    ) -> Bool {
379
-        if typeCount.isContentAlias {
380
-            return false
381
-        }
382
-
383
-        if typeCount.detailCache?.matchesBaseline(baselineID) == true {
384
-            return false
385
-        }
386
-
387
-        guard canBuildDetailCache(typeCount),
388
-              baseline.map(canBuildDetailCache(_:)) ?? true else {
389
-            return false
390
-        }
391
-
392
-        return true
393
-    }
394
-
395
-    @MainActor private static func canBuildDetailCache(_ typeCount: TypeCount) -> Bool {
396
-        typeCount.count <= 0 || typeCount.recordArchiveData != nil
397
-    }
398
-
399
-    private static func detailCacheMetadata(current: TypeCount, previous: TypeCount?, source: String) -> [String: String] {
400
-        [
401
-            "source": source,
402
-            "type": current.typeIdentifier,
403
-            "currentCount": "\(current.count)",
404
-            "previousCount": "\(previous?.count ?? 0)",
405
-            "currentArchive": current.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
406
-            "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
407
-            "isAlias": "\(current.isContentAlias)"
408
-        ]
409
-    }
410
-
411
-    private static func buildSummary(snapshot: HealthSnapshot, incoming: SnapshotDelta?, outgoing: SnapshotDelta?) -> String {
412
-        let position: String
413
-        if incoming == nil && outgoing == nil { position = "standalone" }
414
-        else if incoming == nil { position = "oldest" }
415
-        else if outgoing == nil { position = "latest" }
416
-        else { position = "intermediate" }
417
-        return "Deleted \(position) snapshot \(snapshot.id) at \(snapshot.timestamp)"
418
-    }
419
-
420
-    enum LifecycleError: Error {
421
-        case missingNeighbor
422
-    }
423
-}
424
-
425
-private extension Bundle {
426
-    var appBuildVersion: String {
427
-        let version = infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
428
-        let build = infoDictionary?["CFBundleVersion"] as? String ?? ""
429
-        return "\(version) (\(build))"
430
-    }
431
-}
+0 -168
HealthProbe/Utilities/TypeCountArchiveRepair.swift
@@ -1,168 +0,0 @@
1
-import Foundation
2
-import SwiftData
3
-
4
-struct TypeCountDetailCacheResolution: Sendable {
5
-    let cache: TypeCountDetailCache?
6
-    let diagnostic: String
7
-}
8
-
9
-extension TypeCount {
10
-    private static let detailCacheResolverVersion = "resolver-v5"
11
-
12
-    @MainActor
13
-    func ensureRecordArchiveDataIfNeeded() -> Bool {
14
-        if count <= 0 {
15
-            return true
16
-        }
17
-
18
-        if let existingArchive = recordArchiveData {
19
-            if HealthRecordArchive.isCompact(existingArchive) {
20
-                return true
21
-            }
22
-
23
-            guard let compactArchive = HealthRecordArchive.compactedIfNeeded(existingArchive) else {
24
-                return false
25
-            }
26
-            recordArchiveData = compactArchive
27
-            return true
28
-        }
29
-
30
-        let legacyRecords = records ?? []
31
-        guard !legacyRecords.isEmpty else {
32
-            return false
33
-        }
34
-
35
-        let values = legacyRecords.map { record in
36
-            HealthRecordValue(
37
-                typeIdentifier: record.typeIdentifier,
38
-                sampleUUIDHash: record.sampleUUIDHash,
39
-                recordFingerprint: record.recordFingerprint,
40
-                startDate: record.startDate,
41
-                endDate: record.endDate,
42
-                displayValue: record.displayValue
43
-            )
44
-        }
45
-        guard let archive = HealthRecordArchive.encode(values) else {
46
-            return false
47
-        }
48
-
49
-        recordArchiveData = archive
50
-        records?.removeAll()
51
-        return true
52
-    }
53
-
54
-    @MainActor
55
-    func resolveDetailCache(
56
-        previous: TypeCount?,
57
-        baselineSnapshotID: UUID?,
58
-        context: ModelContext,
59
-        source: String
60
-    ) -> TypeCountDetailCache? {
61
-        resolveDetailCacheWithDiagnostics(
62
-            previous: previous,
63
-            baselineSnapshotID: baselineSnapshotID,
64
-            context: context,
65
-            source: source
66
-        ).cache
67
-    }
68
-
69
-    @MainActor
70
-    func resolveDetailCacheWithDiagnostics(
71
-        previous: TypeCount?,
72
-        baselineSnapshotID: UUID?,
73
-        context: ModelContext,
74
-        source: String
75
-    ) -> TypeCountDetailCacheResolution {
76
-        if let cache = detailCache,
77
-           cache.matchesBaseline(baselineSnapshotID) {
78
-            return TypeCountDetailCacheResolution(
79
-                cache: cache,
80
-                diagnostic: detailCacheDiagnostic(
81
-                    previous: previous,
82
-                    baselineSnapshotID: baselineSnapshotID,
83
-                    phase: "cache-hit"
84
-                )
85
-            )
86
-        }
87
-
88
-        let currentArchiveWasMissing = count > 0 && recordArchiveData == nil
89
-        let previousArchiveWasMissing = (previous?.count ?? 0) > 0 && previous?.recordArchiveData == nil
90
-        let currentArchiveAvailable = ensureRecordArchiveDataIfNeeded()
91
-        let previousArchiveAvailable = previous?.ensureRecordArchiveDataIfNeeded() ?? true
92
-
93
-        guard currentArchiveAvailable, previousArchiveAvailable else {
94
-            let diagnostic = detailCacheDiagnostic(
95
-                previous: previous,
96
-                baselineSnapshotID: baselineSnapshotID,
97
-                phase: "missing-archive"
98
-            )
99
-            MemoryLog.log("\(source).detailCache.resolveUnavailable", metadata: detailCacheMetadata(previous: previous).merging([
100
-                "diagnostic": diagnostic
101
-            ]) { _, new in new })
102
-            return TypeCountDetailCacheResolution(cache: nil, diagnostic: diagnostic)
103
-        }
104
-
105
-        MemoryLog.log("\(source).detailCache.buildBegin", metadata: detailCacheMetadata(previous: previous))
106
-        let cache = TypeCountDetailCacheBuilder.build(
107
-            current: self,
108
-            previous: previous,
109
-            baselineSnapshotID: baselineSnapshotID
110
-        )
111
-        let diagnostic = detailCacheDiagnostic(
112
-            previous: previous,
113
-            baselineSnapshotID: baselineSnapshotID,
114
-            phase: cache == nil ? "build-nil" : "built"
115
-        )
116
-        MemoryLog.log("\(source).detailCache.buildEnd", metadata: [
117
-            "source": source,
118
-            "type": typeIdentifier,
119
-            "cacheBuilt": "\(cache != nil)",
120
-            "diagnostic": diagnostic
121
-        ])
122
-
123
-        if let cache {
124
-            setDetailCache(cache)
125
-        }
126
-
127
-        if cache != nil ||
128
-            (currentArchiveWasMissing && recordArchiveData != nil) ||
129
-            (previousArchiveWasMissing && previous?.recordArchiveData != nil) {
130
-            try? context.save()
131
-        }
132
-
133
-        return TypeCountDetailCacheResolution(cache: cache, diagnostic: diagnostic)
134
-    }
135
-
136
-    private func detailCacheMetadata(previous: TypeCount?) -> [String: String] {
137
-        [
138
-            "type": typeIdentifier,
139
-            "currentCount": "\(count)",
140
-            "previousCount": "\(previous?.count ?? 0)",
141
-            "currentArchive": recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
142
-            "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil"
143
-        ]
144
-    }
145
-
146
-    private func detailCacheDiagnostic(
147
-        previous: TypeCount?,
148
-        baselineSnapshotID: UUID?,
149
-        phase: String
150
-    ) -> String {
151
-        let baseline = baselineSnapshotID?.uuidString.prefix(6) ?? "none"
152
-        let currentArchive = archiveDebugLabel(for: self)
153
-        let previousArchive = archiveDebugLabel(for: previous)
154
-        return "\(Self.detailCacheResolverVersion) phase=\(phase) base=\(baseline) curr=\(currentArchive) prev=\(previousArchive)"
155
-    }
156
-
157
-    private func archiveDebugLabel(for typeCount: TypeCount?) -> String {
158
-        guard let typeCount else { return "none" }
159
-        if let archive = typeCount.recordArchiveData {
160
-            let formatSuffix = HealthRecordArchive.isCompact(archive) ? "-c" : "-p"
161
-            return "\(MemoryLog.format(UInt64(archive.count)))\(formatSuffix)"
162
-        }
163
-        if typeCount.count <= 0 {
164
-            return "empty"
165
-        }
166
-        return "missing"
167
-    }
168
-}