Add explicit @Attribute annotations to all model properties to prevent SwiftData's macro from inferring incorrect type mappings. This resolves the '__NSCFNumber to NSString' casting errors that occurred because SwiftData was incorrectly treating String fields as Int. - Make all UUID IDs unique with @Attribute(.unique) - Initialize all properties in __init__ to ensure correct types - Ensure no implicit type inference issues This should resolve the schema corruption errors completely. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -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 |
|
@@ -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 |
} |
@@ -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 |
} |
@@ -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 |
@@ -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 |
} |
@@ -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 |
} |
@@ -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 |
|
@@ -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 |
} |