Showing 8 changed files with 122 additions and 102 deletions
+15 -10
HealthProbe/Models/AnomalyRecord.swift
@@ -2,23 +2,28 @@ import Foundation
2 2
 import SwiftData
3 3
 
4 4
 @Model final class AnomalyRecord {
5
-    var id: UUID = UUID()
6
-    var detectedAt: Date = Date.now
7
-    var snapshotID: UUID = UUID()
8
-    var deltaID: UUID? = nil        // set inside AnomalyDetector.detect(), never by caller
9
-    var deviceID: String = ""
10
-    var anomalyTypeRaw: String = AnomalyType.deletion.rawValue
11
-    var severityRaw: String = Severity.info.rawValue
12
-    var typeIdentifier: String? = nil   // nil = cross-type (syncAnomaly)
13
-    var message: String = ""
14
-    var isResolved: Bool = false
5
+    @Attribute(.unique) var id: UUID
6
+    @Attribute var detectedAt: Date
7
+    @Attribute var snapshotID: UUID
8
+    @Attribute var deltaID: UUID?
9
+    @Attribute var deviceID: String
10
+    @Attribute var anomalyTypeRaw: String
11
+    @Attribute var severityRaw: String
12
+    @Attribute var typeIdentifier: String?
13
+    @Attribute var message: String
14
+    @Attribute var isResolved: Bool
15 15
 
16 16
     init(snapshotID: UUID, deviceID: String, anomalyType: AnomalyType, severity: Severity) {
17 17
         self.id = UUID()
18
+        self.detectedAt = Date.now
18 19
         self.snapshotID = snapshotID
20
+        self.deltaID = nil
19 21
         self.deviceID = deviceID
20 22
         self.anomalyTypeRaw = anomalyType.rawValue
21 23
         self.severityRaw = severity.rawValue
24
+        self.typeIdentifier = nil
25
+        self.message = ""
26
+        self.isResolved = false
22 27
     }
23 28
 }
24 29
 
+5 -3
HealthProbe/Models/DeviceProfile.swift
@@ -3,11 +3,13 @@ import SwiftData
3 3
 
4 4
 @Model
5 5
 final class DeviceProfile {
6
-    var deviceID: String = ""
7
-    var name: String = ""
8
-    var colorTag: String = "blue"
6
+    @Attribute(.unique) var deviceID: String
7
+    @Attribute var name: String
8
+    @Attribute var colorTag: String
9 9
 
10 10
     init(deviceID: String) {
11 11
         self.deviceID = deviceID
12
+        self.name = ""
13
+        self.colorTag = "blue"
12 14
     }
13 15
 }
+35 -36
HealthProbe/Models/HealthSnapshot.swift
@@ -3,42 +3,26 @@ import SwiftData
3 3
 
4 4
 @Model
5 5
 final class HealthSnapshot {
6
-    var id: UUID = UUID()
7
-    var timestamp: Date = Date.now
8
-    var osVersion: String = ""
9
-    var deviceName: String = ""
10
-    var deviceID: String = ""
11
-
12
-    // Chain linkage
13
-    // localSequenceNumber is UI/debug only — used ONLY during local snapshot creation to find
14
-    // the latest local candidate. Never use this for chain reconstruction; use previousSnapshotID.
15
-    var localSequenceNumber: Int = 0
16
-    var previousSnapshotID: UUID? = nil  // sole source of chain truth — all reconstruction uses this
17
-    var isChainStart: Bool = false
18
-    var recoveredDeviceID: Bool = false  // true when DB was wiped but Keychain had a deviceID
19
-
20
-    // Quality
21
-    var snapshotQuality: SnapshotQuality = SnapshotQuality.complete
22
-    var anomalyFlagsJSON: String = "[]"  // JSON-encoded [String] — CloudKit-safe array storage
23
-
24
-    // Trigger context
25
-    var triggerReason: String = "manual"  // "manual" | "observerCallback" | "backgroundRefresh"
26
-    var isPostRestore: Bool = false
27
-    // isPostRestore suppression: forwarded past low-quality successors until consumed by a .complete delta
28
-    var isPostRestoreInferred: Bool = false
29
-    var isPostRestoreSuppressedDeltaID: UUID? = nil  // set to the deltaID that consumed the suppression token
30
-
31
-    // Device identity — informational only, never used for chain linkage
32
-    var hardwareModel: String = ""
33
-    var appBuildVersion: String = ""
34
-
35
-    // Registry fingerprint — used to suppress false anomalies from type set changes
36
-    var monitoredTypeSetHash: String = ""
37
-    var monitoredRegistryVersion: Int = 0
38
-
39
-    // Timezone context — used by DeltaService to suppress false YearlyCount deltas across timezone changes
40
-    var yearlyCountTimezoneIdentifier: String = ""
41
-
6
+    @Attribute(.unique) var id: UUID
7
+    @Attribute var timestamp: Date
8
+    @Attribute var osVersion: String
9
+    @Attribute var deviceName: String
10
+    @Attribute var deviceID: String
11
+    @Attribute var localSequenceNumber: Int
12
+    @Attribute var previousSnapshotID: UUID?
13
+    @Attribute var isChainStart: Bool
14
+    @Attribute var recoveredDeviceID: Bool
15
+    @Attribute var snapshotQuality: SnapshotQuality
16
+    @Attribute var anomalyFlagsJSON: String
17
+    @Attribute var triggerReason: String
18
+    @Attribute var isPostRestore: Bool
19
+    @Attribute var isPostRestoreInferred: Bool
20
+    @Attribute var isPostRestoreSuppressedDeltaID: UUID?
21
+    @Attribute var hardwareModel: String
22
+    @Attribute var appBuildVersion: String
23
+    @Attribute var monitoredTypeSetHash: String
24
+    @Attribute var monitoredRegistryVersion: Int
25
+    @Attribute var yearlyCountTimezoneIdentifier: String
42 26
     @Relationship(deleteRule: .cascade, inverse: \TypeCount.snapshot)
43 27
     var typeCounts: [TypeCount]?
44 28
 
@@ -53,6 +37,21 @@ final class HealthSnapshot {
53 37
         self.osVersion = osVersion
54 38
         self.deviceName = deviceName
55 39
         self.deviceID = deviceID
40
+        self.localSequenceNumber = 0
41
+        self.previousSnapshotID = nil
42
+        self.isChainStart = false
43
+        self.recoveredDeviceID = false
44
+        self.snapshotQuality = .complete
45
+        self.anomalyFlagsJSON = "[]"
46
+        self.triggerReason = "manual"
47
+        self.isPostRestore = false
48
+        self.isPostRestoreInferred = false
49
+        self.isPostRestoreSuppressedDeltaID = nil
50
+        self.hardwareModel = ""
51
+        self.appBuildVersion = ""
52
+        self.monitoredTypeSetHash = ""
53
+        self.monitoredRegistryVersion = 0
54
+        self.yearlyCountTimezoneIdentifier = ""
56 55
         self.typeCounts = []
57 56
     }
58 57
 }
+9 -7
HealthProbe/Models/OperationLog.swift
@@ -2,17 +2,19 @@ import Foundation
2 2
 import SwiftData
3 3
 
4 4
 @Model final class OperationLog {
5
-    var id: UUID = UUID()
6
-    var timestamp: Date = Date.now
7
-    var operationType: String = ""              // "delete" | "merge"
8
-    var affectedSnapshotIDsJSON: String = "[]"  // JSON-encoded [String] — CloudKit-safe
9
-    var summary: String = ""
10
-    var operationDeviceID: String = ""
11
-    var operationAppBuildVersion: String = ""
5
+    @Attribute(.unique) var id: UUID
6
+    @Attribute var timestamp: Date
7
+    @Attribute var operationType: String
8
+    @Attribute var affectedSnapshotIDsJSON: String
9
+    @Attribute var summary: String
10
+    @Attribute var operationDeviceID: String
11
+    @Attribute var operationAppBuildVersion: String
12 12
 
13 13
     init(operationType: String, summary: String, deviceID: String, appBuildVersion: String) {
14 14
         self.id = UUID()
15
+        self.timestamp = Date.now
15 16
         self.operationType = operationType
17
+        self.affectedSnapshotIDsJSON = "[]"
16 18
         self.summary = summary
17 19
         self.operationDeviceID = deviceID
18 20
         self.operationAppBuildVersion = appBuildVersion
+14 -10
HealthProbe/Models/SnapshotDelta.swift
@@ -2,22 +2,26 @@ import Foundation
2 2
 import SwiftData
3 3
 
4 4
 @Model final class SnapshotDelta {
5
-    var id: UUID = UUID()
6
-    var fromSnapshotID: UUID = UUID()
7
-    var toSnapshotID: UUID = UUID()
8
-    var deviceID: String = ""
9
-    var computedAt: Date = Date.now
10
-    var checksumBefore: String = ""
11
-    var checksumAfter: String = ""
12
-    var isCloudKitImported: Bool = false
13
-
5
+    @Attribute(.unique) var id: UUID
6
+    @Attribute var fromSnapshotID: UUID
7
+    @Attribute var toSnapshotID: UUID
8
+    @Attribute var deviceID: String
9
+    @Attribute var computedAt: Date
10
+    @Attribute var checksumBefore: String
11
+    @Attribute var checksumAfter: String
12
+    @Attribute var isCloudKitImported: Bool
14 13
     @Relationship(deleteRule: .cascade, inverse: \TypeDelta.delta)
15
-    var typeDeltas: [TypeDelta]? = []
14
+    var typeDeltas: [TypeDelta]?
16 15
 
17 16
     init(fromSnapshotID: UUID, toSnapshotID: UUID, deviceID: String) {
18 17
         self.id = UUID()
19 18
         self.fromSnapshotID = fromSnapshotID
20 19
         self.toSnapshotID = toSnapshotID
21 20
         self.deviceID = deviceID
21
+        self.computedAt = Date.now
22
+        self.checksumBefore = ""
23
+        self.checksumAfter = ""
24
+        self.isCloudKitImported = false
25
+        self.typeDeltas = []
22 26
     }
23 27
 }
+16 -16
HealthProbe/Models/TypeCount.swift
@@ -3,30 +3,30 @@ import SwiftData
3 3
 
4 4
 @Model
5 5
 final class TypeCount {
6
-    var id: UUID = UUID()
7
-    var typeIdentifier: String = ""
8
-    var displayName: String = ""
9
-    // count = -1 → query could not be completed (failed, timed out, unauthorized, unsupported, loading)
10
-    // count = 0  → valid ONLY when quality == .complete; means HK returned no records
11
-    var count: Int = 0
12
-    var hash: String = ""               // SHA256(typeIdentifier|totalCount|earliestDate|latestDate)
13
-    var earliestDate: Date? = nil
14
-    var latestDate: Date? = nil
15
-    var quality: SnapshotQuality = SnapshotQuality.complete
16
-    // true when HKObjectType factory returned nil for this identifier (unsupported on this OS/device)
17
-    var isUnsupported: Bool = false
18
-
19
-    var snapshot: HealthSnapshot?
20
-
6
+    @Attribute(.unique) var id: UUID
7
+    @Attribute var typeIdentifier: String
8
+    @Attribute var displayName: String
9
+    @Attribute var count: Int
10
+    @Attribute var hash: String
11
+    @Attribute var earliestDate: Date?
12
+    @Attribute var latestDate: Date?
13
+    @Attribute var quality: SnapshotQuality
14
+    @Attribute var isUnsupported: Bool
15
+    @Attribute var snapshot: HealthSnapshot?
21 16
     @Relationship(deleteRule: .cascade, inverse: \YearlyCount.typeCount)
22
-    var yearlyCounts: [YearlyCount]? = []
17
+    var yearlyCounts: [YearlyCount]?
23 18
 
24 19
     init(typeIdentifier: String, displayName: String, count: Int, quality: SnapshotQuality = SnapshotQuality.complete) {
25 20
         self.id = UUID()
26 21
         self.typeIdentifier = typeIdentifier
27 22
         self.displayName = displayName
28 23
         self.count = count
24
+        self.hash = ""
25
+        self.earliestDate = nil
26
+        self.latestDate = nil
29 27
         self.quality = quality
28
+        self.isUnsupported = false
29
+        self.snapshot = nil
30 30
         self.yearlyCounts = []
31 31
     }
32 32
 }
+22 -15
HealthProbe/Models/TypeDelta.swift
@@ -2,27 +2,34 @@ import Foundation
2 2
 import SwiftData
3 3
 
4 4
 @Model final class TypeDelta {
5
-    var id: UUID = UUID()
6
-    var typeIdentifier: String = ""
7
-    var displayName: String = ""
8
-    var countDelta: Int = 0
9
-    var hashBefore: String = ""
10
-    var hashAfter: String = ""
11
-    var qualityBeforeRaw: String? = nil   // SnapshotQuality.rawValue; nil if type didn't exist
12
-    var qualityAfterRaw: String? = nil    // SnapshotQuality.rawValue; nil if type doesn't exist
13
-    var transitionRaw: String = TypeTransition.unchanged.rawValue
14
-    var reasonRaw: String = TypeDeltaReason.normal.rawValue
15
-    var yearlyCountNote: String = ""
16
-    var isCloudKitImported: Bool = false
17
-    // nil is valid ONLY as a transient CloudKit sync state (isCloudKitImported == true).
18
-    // Locally created TypeDeltas must always have a parent at save time — nil on a locally
19
-    // committed delta is a bug. Chain validation tolerates nil only for CloudKit-pending records.
5
+    @Attribute(.unique) var id: UUID
6
+    @Attribute var typeIdentifier: String
7
+    @Attribute var displayName: String
8
+    @Attribute var countDelta: Int
9
+    @Attribute var hashBefore: String
10
+    @Attribute var hashAfter: String
11
+    @Attribute var qualityBeforeRaw: String?
12
+    @Attribute var qualityAfterRaw: String?
13
+    @Attribute var transitionRaw: String
14
+    @Attribute var reasonRaw: String
15
+    @Attribute var yearlyCountNote: String
16
+    @Attribute var isCloudKitImported: Bool
20 17
     var delta: SnapshotDelta?
21 18
 
22 19
     init(typeIdentifier: String, displayName: String) {
23 20
         self.id = UUID()
24 21
         self.typeIdentifier = typeIdentifier
25 22
         self.displayName = displayName
23
+        self.countDelta = 0
24
+        self.hashBefore = ""
25
+        self.hashAfter = ""
26
+        self.qualityBeforeRaw = nil
27
+        self.qualityAfterRaw = nil
28
+        self.transitionRaw = TypeTransition.unchanged.rawValue
29
+        self.reasonRaw = TypeDeltaReason.normal.rawValue
30
+        self.yearlyCountNote = ""
31
+        self.isCloudKitImported = false
32
+        self.delta = nil
26 33
     }
27 34
 }
28 35
 
+6 -5
HealthProbe/Models/YearlyCount.swift
@@ -3,11 +3,11 @@ import SwiftData
3 3
 
4 4
 @Model
5 5
 final class YearlyCount {
6
-    var id: UUID = UUID()
7
-    var year: Int = 0
8
-    var count: Int = 0              // -1 if parent TypeCount.quality != .complete; never fake zero
9
-    var typeIdentifier: String = ""
10
-    var isApproximate: Bool = false // true when bin granularity > daily (year attribution unreliable)
6
+    @Attribute(.unique) var id: UUID
7
+    @Attribute var year: Int
8
+    @Attribute var count: Int
9
+    @Attribute var typeIdentifier: String
10
+    @Attribute var isApproximate: Bool
11 11
     var typeCount: TypeCount?
12 12
 
13 13
     init(year: Int, count: Int, typeIdentifier: String, isApproximate: Bool = false) {
@@ -16,5 +16,6 @@ final class YearlyCount {
16 16
         self.count = count
17 17
         self.typeIdentifier = typeIdentifier
18 18
         self.isApproximate = isApproximate
19
+        self.typeCount = nil
19 20
     }
20 21
 }