import CoreData import CloudKit import Combine import WidgetKit extension Notification.Name { static let cloudKitForceReload = Notification.Name("cloudKitForceReload") } class CoreDataStack: ObservableObject { static let shared = CoreDataStack() static let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal" static let cloudKitContainerIdentifier = "iCloud.com.alexandrevazquez.portfoliojournal" private static var cloudKitEnabled: Bool { UserDefaults.standard.bool(forKey: "cloudSyncEnabled") } private static var appGroupEnabled: Bool { true } private static func migrateStoreIfNeeded(from sourceURL: URL, to destinationURL: URL) { let fileManager = FileManager.default guard fileManager.fileExists(atPath: sourceURL.path) else { return } let sourceHasData = storeHasData(at: sourceURL) let destinationHasData = storeHasData(at: destinationURL) guard sourceHasData, !destinationHasData else { return } removeStoreFilesIfNeeded(at: destinationURL) let relatedSuffixes = ["", "-wal", "-shm"] for suffix in relatedSuffixes { let source = URL(fileURLWithPath: sourceURL.path + suffix) let destination = URL(fileURLWithPath: destinationURL.path + suffix) guard fileManager.fileExists(atPath: source.path), !fileManager.fileExists(atPath: destination.path) else { continue } do { try fileManager.copyItem(at: source, to: destination) } catch { print("Core Data store migration failed for \(source.lastPathComponent): \(error)") } } } private static func removeStoreFilesIfNeeded(at url: URL) { let fileManager = FileManager.default let relatedSuffixes = ["", "-wal", "-shm"] for suffix in relatedSuffixes { let fileURL = URL(fileURLWithPath: url.path + suffix) guard fileManager.fileExists(atPath: fileURL.path) else { continue } do { try fileManager.removeItem(at: fileURL) } catch { print("Failed to remove existing store file \(fileURL.lastPathComponent): \(error)") } } } private static func storeHasData(at url: URL) -> Bool { let fileManager = FileManager.default guard fileManager.fileExists(atPath: url.path), let model = NSManagedObjectModel.mergedModel(from: [Bundle.main]) else { return false } let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model) do { try coordinator.addPersistentStore( ofType: NSSQLiteStoreType, configurationName: nil, at: url, options: [NSReadOnlyPersistentStoreOption: true] ) } catch { print("Failed to open store for data check: \(error)") return false } let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) context.persistentStoreCoordinator = coordinator let snapshotRequest = NSFetchRequest(entityName: "Snapshot") snapshotRequest.resultType = .countResultType let snapshotCount = (try? context.count(for: snapshotRequest)) ?? 0 if snapshotCount > 0 { return true } let sourceRequest = NSFetchRequest(entityName: "InvestmentSource") sourceRequest.resultType = .countResultType let sourceCount = (try? context.count(for: sourceRequest)) ?? 0 return sourceCount > 0 } private static func resolveStoreURL() -> URL { let defaultURL = NSPersistentContainer.defaultDirectoryURL() .appendingPathComponent("PortfolioJournal.sqlite") guard appGroupEnabled, let appGroupURL = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)? .appendingPathComponent("PortfolioJournal.sqlite") else { if appGroupEnabled { print("App Group unavailable; using default store URL: \(defaultURL)") } return defaultURL } migrateStoreIfNeeded(from: defaultURL, to: appGroupURL) return appGroupURL } @Published private(set) var isLoaded = false @Published private(set) var lastImportDate: Date? @Published private(set) var lastExportDate: Date? @Published private(set) var isSyncing = false @Published private(set) var lastSyncError: String? /// Full recursive dump of the last sync error tree (leaf CKError codes, record /// types, retry-after, raw userInfo keys). Surfaced in the copyable diagnostics /// so we can see WHICH record CloudKit rejects, not just the opaque code 2. @Published private(set) var lastSyncErrorDetail: String? /// Human-readable, actionable explanation of the last sync error (nil if none). @Published private(set) var lastSyncErrorHint: String? /// True only when sync is genuinely broken (never synced + total failure). /// Partial failures after a working sync are informational, not alarming. @Published private(set) var syncErrorIsCritical = false var lastSyncDate: Date? { lastImportDate } var localSourceCount: Int { let request = NSFetchRequest(entityName: "InvestmentSource") return (try? viewContext.count(for: request)) ?? 0 } var localSnapshotCount: Int { let request = NSFetchRequest(entityName: "Snapshot") return (try? viewContext.count(for: request)) ?? 0 } private init() { // Register CloudKit event observer BEFORE the container is created so we // never miss an import/export event that fires during store loading. if Self.cloudKitEnabled { NotificationCenter.default.addObserver( self, selector: #selector(cloudKitEventChanged(_:)), name: NSPersistentCloudKitContainer.eventChangedNotification, object: nil ) } } lazy var persistentContainer: NSPersistentContainer = { let container: NSPersistentContainer if Self.cloudKitEnabled { container = NSPersistentCloudKitContainer(name: "PortfolioJournal") } else { container = NSPersistentContainer(name: "PortfolioJournal") } // App Group store URL for sharing with widgets. Fall back if not entitled. let storeURL = Self.resolveStoreURL() let description = NSPersistentStoreDescription(url: storeURL) description.shouldMigrateStoreAutomatically = true description.shouldInferMappingModelAutomatically = true // Always enable history tracking so data created before CloudKit was enabled // is visible to NSPersistentCloudKitContainer when sync is later turned on. description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) if Self.cloudKitEnabled { description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions( containerIdentifier: Self.cloudKitContainerIdentifier ) } container.persistentStoreDescriptions = [description] container.loadPersistentStores { [weak self] description, error in if let error = error as NSError? { // In production, handle this error appropriately print("Core Data failed to load: \(error), \(error.userInfo)") } else { print("Core Data loaded successfully at: \(description.url?.path ?? "unknown")") #if DEBUG // Dev-only, opt-in: push the FULL model schema to the Development CloudKit // environment so optional fields that were never exported by a Debug run // (e.g. monthlyContribution on InvestmentSource, allocationTarget on Category) // get materialised — otherwise they can't be deployed to Production and every // Production export rejects those records (bare CKErrorPartialFailure). // Run ONCE from Xcode with launch arg -InitializeCloudKitSchema, watch for the // ✅ log, then deploy to Production in the CloudKit Console and remove the arg. if ProcessInfo.processInfo.arguments.contains("-InitializeCloudKitSchema"), let ckContainer = container as? NSPersistentCloudKitContainer { do { try ckContainer.initializeCloudKitSchema(options: []) print("✅ CloudKit schema initialized in Development. Now open the CloudKit Console → Deploy Schema Changes → Production.") } catch { print("❌ initializeCloudKitSchema failed: \(error)") } } #endif } DispatchQueue.main.async { self?.isLoaded = true // Merge the pre-stable-UUID default duplicates. Safe now: the merge // reassigns children then deletes losers, and the relevant relationships // use the Nullify delete rule so a propagated parent-delete can never // cascade-wipe children (unlike the build-68 Cascade disaster). self?.mergeDefaultDuplicates() MonthlyCheckInStore.migrateIfNeeded() // Migrate device-local UserDefaults data into Core Data so it syncs via iCloud. MonthlyContributionStore.migrateIfNeeded(context: container.viewContext) AllocationTargetStore.migrateIfNeeded(context: container.viewContext) // Seed demo data when running in screenshot capture mode. ScreenshotMode.seedIfNeeded() // Apply share-extension captures and publish the source mirror. Task { @MainActor in SharedQuickUpdateSync.ingestPending() SharedQuickUpdateSync.refreshMirror() } } } // Merge policy - remote changes win container.viewContext.automaticallyMergesChangesFromParent = true container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy // Performance optimizations container.viewContext.undoManager = nil container.viewContext.shouldDeleteInaccessibleFaults = true if Self.cloudKitEnabled { NotificationCenter.default.addObserver( self, selector: #selector(processRemoteChanges), name: .NSPersistentStoreRemoteChange, object: container.persistentStoreCoordinator ) } return container }() var viewContext: NSManagedObjectContext { return persistentContainer.viewContext } // MARK: - Cleanup Duplicates /// Removes duplicate objects that have the same UUID, keeping only the oldest one. /// This fixes data corruption from race conditions during object creation, and also /// handles the case where a CloudKit first-time sync imports records that already exist /// locally (because they were created before CloudKit was enabled). @discardableResult func cleanupDuplicateObjects() -> Int { var totalRemoved = 0 let context = viewContext context.performAndWait { // Clean up Snapshots first — before cascade rules from InvestmentSource fire, // so we deduplicate by UUID and not rely solely on cascade. totalRemoved += cleanupDuplicates(entityName: "Snapshot", idKey: "id", context: context) totalRemoved += cleanupDuplicates(entityName: "Goal", idKey: "id", context: context) totalRemoved += cleanupDuplicates(entityName: "Account", idKey: "id", context: context) totalRemoved += cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context) totalRemoved += cleanupDuplicates(entityName: "Category", idKey: "id", context: context) if context.hasChanges { try? context.save() } } return totalRemoved } @discardableResult private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) -> Int { let request = NSFetchRequest(entityName: entityName) request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)] guard let objects = try? context.fetch(request) else { return 0 } var seenIds = Set() var objectsToDelete: [NSManagedObject] = [] for object in objects { guard let objectId = object.value(forKey: idKey) as? UUID else { continue } if seenIds.contains(objectId) { objectsToDelete.append(object) } else { seenIds.insert(objectId) } } if !objectsToDelete.isEmpty { print("[Dedup] Removing \(objectsToDelete.count) duplicate \(entityName) objects") for object in objectsToDelete { context.delete(object) } } return objectsToDelete.count } // MARK: - Logical Duplicate Cleanup (cross-device / cross-environment) /// Removes *logical* duplicates: records that represent the same real-world /// entity but carry DIFFERENT UUIDs. These appear when the same data is /// created independently on two devices — or in the Development vs Production /// CloudKit environments — and then merged by CloudKit, which keys on the /// record name (our UUID) and therefore never recognises them as the same, /// keeping both. The classic trigger here: `createDefaultAccountIfNeeded` and /// `ensureDefaultCategoriesExist` seed defaults on every device at launch /// *before* the first import arrives, so each device contributes its own /// "Default" account + 8 categories → they pile up (8 accounts, 23 categories). /// /// The UUID-based `cleanupDuplicateObjects()` cannot catch these because the /// UUIDs differ. Here we group by a stable *business key* and keep a single /// deterministic winner (earliest `createdAt`, tie-broken by lowest UUID — /// both synced fields, so every device independently picks the SAME survivor /// and the deletes converge instead of racing each other into data loss), /// reassigning children onto the survivor before deleting the losers. @discardableResult func cleanupLogicalDuplicates() -> Int { var removed = 0 let context = viewContext context.performAndWait { // Parents first: re-point children onto the surviving parent so the // child keys (which embed the parent's id) stay stable for later passes. removed += mergeLogicalDuplicates( entityName: "Account", childRelationships: ["sources", "goals"], context: context, key: { obj in let name = (obj.value(forKey: "name") as? String ?? "") .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return name.isEmpty ? "" : "account|\(name)" }) removed += mergeLogicalDuplicates( entityName: "Category", childRelationships: ["sources"], context: context, key: { obj in let name = (obj.value(forKey: "name") as? String ?? "") .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return name.isEmpty ? "" : "category|\(name)" }) removed += mergeLogicalDuplicates( entityName: "InvestmentSource", childRelationships: ["snapshots", "transactions"], context: context, key: { obj in let name = (obj.value(forKey: "name") as? String ?? "") .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !name.isEmpty else { return "" } let acc = (obj.value(forKey: "account") as? NSManagedObject)? .value(forKey: "id") as? UUID return "source|\(acc?.uuidString ?? "none")|\(name)" }) removed += mergeLogicalDuplicates( entityName: "Snapshot", childRelationships: [], context: context, key: { obj in // Only dedup snapshots that share the same source, same day AND // same value — a true copy. Orphans (nil source) are left alone. guard let src = (obj.value(forKey: "source") as? NSManagedObject)? .value(forKey: "id") as? UUID, let date = obj.value(forKey: "date") as? Date else { return "" } let day = (date.timeIntervalSinceReferenceDate / 86_400).rounded(.down) let value = (obj.value(forKey: "value") as? NSDecimalNumber)? .stringValue ?? "nil" return "snapshot|\(src.uuidString)|\(day)|\(value)" }) removed += mergeLogicalDuplicates( entityName: "Goal", childRelationships: [], context: context, key: { obj in let name = (obj.value(forKey: "name") as? String ?? "") .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !name.isEmpty else { return "" } let target = (obj.value(forKey: "targetAmount") as? NSDecimalNumber)? .stringValue ?? "nil" return "goal|\(name)|\(target)" }) removed += mergeLogicalDuplicates( entityName: "JournalEntry", childRelationships: [], context: context, key: { obj in let monthKey = (obj.value(forKey: "monthKey") as? String ?? "") .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return monthKey.isEmpty ? "" : "journal|\(monthKey)" }) if context.hasChanges { try? context.save() } } return removed } /// Groups objects of `entityName` by `key`, keeps one deterministic winner /// per group, moves every child in `childRelationships` from the losers onto /// the winner, then deletes the losers. An empty key means "never dedup this /// object" (degenerate identity — e.g. no name, orphan snapshot). @discardableResult private func mergeLogicalDuplicates( entityName: String, childRelationships: [String], context: NSManagedObjectContext, key: (NSManagedObject) -> String ) -> Int { let request = NSFetchRequest(entityName: entityName) guard let objects = try? context.fetch(request), !objects.isEmpty else { return 0 } var groups: [String: [NSManagedObject]] = [:] for obj in objects { let k = key(obj) guard !k.isEmpty else { continue } groups[k, default: []].append(obj) } var removed = 0 for (_, group) in groups where group.count > 1 { // Deterministic ordering → same winner on every device. let sorted = group.sorted { a, b in let ca = (a.value(forKey: "createdAt") as? Date) ?? .distantPast let cb = (b.value(forKey: "createdAt") as? Date) ?? .distantPast if ca != cb { return ca < cb } let ia = (a.value(forKey: "id") as? UUID)?.uuidString ?? "" let ib = (b.value(forKey: "id") as? UUID)?.uuidString ?? "" return ia < ib } let winner = sorted[0] for loser in sorted.dropFirst() { for rel in childRelationships { let children = loser.mutableSetValue(forKey: rel) guard children.count > 0 else { continue } let winnerChildren = winner.mutableSetValue(forKey: rel) // Snapshot children first so the winner is a superset before delete. for child in children.allObjects { winnerChildren.add(child) // reassigns the modeled inverse } } context.delete(loser) removed += 1 } } if removed > 0 { print("[LogicalDedup] \(entityName): removed \(removed) duplicate(s)") } return removed } // MARK: - Merge Default Duplicates (CloudKit-safe) /// Merges the duplicate default Accounts and Categories that piled up before /// the stable-UUID fix (per-device "Default" accounts + default categories with /// random UUIDs). CloudKit-safe now because Account.sources/goals and /// Category.sources use the Nullify delete rule — so a merged-away parent's /// delete can never cascade-wipe children on any device (the build-68 disaster /// was Cascade + cross-device import ordering). Children are reassigned to a /// DETERMINISTIC winner (the stable-UUID record if present, else earliest /// createdAt / lowest UUID) so every device converges on the same survivor. @discardableResult func mergeDefaultDuplicates() -> Int { var merged = 0 let context = viewContext context.performAndWait { merged += mergeDefaultAccounts(in: context) merged += mergeDuplicateCategories(in: context) if context.hasChanges { try? context.save() } } return merged } private func deterministicWinner(_ objects: [NSManagedObject], stableID: UUID?) -> NSManagedObject? { if let stableID, let canonical = objects.first(where: { ($0.value(forKey: "id") as? UUID) == stableID }) { return canonical } return objects.sorted { let a = ($0.value(forKey: "createdAt") as? Date) ?? .distantPast let b = ($1.value(forKey: "createdAt") as? Date) ?? .distantPast if a != b { return a < b } let ida = ($0.value(forKey: "id") as? UUID)?.uuidString ?? "" let idb = ($1.value(forKey: "id") as? UUID)?.uuidString ?? "" return ida < idb }.first } private func reassign(_ children: Any?, key: String, to winner: NSManagedObject) { guard let set = children as? Set else { return } for child in Array(set) { child.setValue(winner, forKey: key) } } private func mergeDefaultAccounts(in context: NSManagedObjectContext) -> Int { let request = NSFetchRequest(entityName: "Account") request.predicate = NSPredicate(format: "name == %@", Account.defaultAccountName) guard let accounts = try? context.fetch(request), accounts.count > 1, let winner = deterministicWinner(accounts, stableID: Account.defaultAccountStableID) else { return 0 } var removed = 0 for loser in accounts where loser !== winner { reassign(loser.value(forKey: "sources"), key: "account", to: winner) reassign(loser.value(forKey: "goals"), key: "account", to: winner) context.delete(loser) removed += 1 } if removed > 0 { print("[MergeDefaults] accounts: removed \(removed)") } return removed } private func mergeDuplicateCategories(in context: NSManagedObjectContext) -> Int { let request = NSFetchRequest(entityName: "Category") guard let categories = try? context.fetch(request), !categories.isEmpty else { return 0 } var byName: [String: [NSManagedObject]] = [:] for c in categories { let name = (c.value(forKey: "name") as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !name.isEmpty else { continue } byName[name, default: []].append(c) } var removed = 0 for (name, group) in byName where group.count > 1 { let stable = Category.stableID(for: name) guard let winner = deterministicWinner(group, stableID: stable) else { continue } for loser in group where loser !== winner { reassign(loser.value(forKey: "sources"), key: "category", to: winner) context.delete(loser) removed += 1 } } if removed > 0 { print("[MergeDefaults] categories: removed \(removed)") } return removed } // MARK: - Save Context func save() { let context = viewContext guard context.hasChanges else { return } do { try context.save() } catch { let nsError = error as NSError print("Core Data save error: \(nsError), \(nsError.userInfo)") } } // MARK: - Background Context func newBackgroundContext() -> NSManagedObjectContext { let context = persistentContainer.newBackgroundContext() context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy context.undoManager = nil return context } func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) { persistentContainer.performBackgroundTask(block) } // MARK: - Remote Change Handling @objc private func processRemoteChanges(_ notification: Notification) { DispatchQueue.main.async { [weak self] in guard let self else { return } // Force viewContext to re-read all objects from the persistent store. self.viewContext.refreshAllObjects() // Remove any duplicates that CloudKit import may have introduced. // This handles the first-time sync case where records existed locally before // CloudKit was enabled: the initial export+import creates duplicate objects. let removed = self.cleanupDuplicateObjects() if removed > 0 { print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import") } // Merge default duplicates after each import (CloudKit-safe via Nullify // rules + deterministic winner → devices converge, no cascade wipes). let mergedDefaults = self.mergeDefaultDuplicates() if mergedDefaults > 0 { print("[RemoteChanges] Merged \(mergedDefaults) default duplicates after CloudKit import") } // Notify repositories to re-fetch unconditionally. NotificationCenter.default.post(name: .cloudKitForceReload, object: nil) self.objectWillChange.send() self.save() self.refreshWidgetData() } } @objc private func cloudKitEventChanged(_ notification: Notification) { guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else { return } DispatchQueue.main.async { [weak self] in guard let self else { return } let isActive = event.endDate == nil self.isSyncing = isActive if !isActive { if event.succeeded, let endDate = event.endDate { self.lastSyncError = nil switch event.type { case .import: self.lastImportDate = endDate case .export: self.lastExportDate = endDate default: break } } else if let error = event.error { let typeLabel = event.type == .import ? "import" : event.type == .export ? "export" : "setup" self.lastSyncError = "\(typeLabel): \(Self.describeError(error))" self.lastSyncErrorDetail = "\(typeLabel)\n\(Self.deepErrorReport(error))" self.lastSyncErrorHint = Self.hint(for: error) // Severity: a partial failure AFTER sync has worked at least // once is background noise (a subset of records CloudKit // retries), NOT a broken-sync alarm. Only a total failure with // no data ever synced is critical. let hasSyncedBefore = self.lastImportDate != nil || self.lastExportDate != nil let isPartial = (error as NSError).code == 2 // CKErrorPartialFailure self.syncErrorIsCritical = !(hasSyncedBefore || isPartial) print("CloudKit \(typeLabel) full error:\n\(error)\nuserInfo: \((error as NSError).userInfo)") } } } } private static func describeError(_ error: Error, depth: Int = 0) -> String { guard depth < 3 else { return "…" } let ns = error as NSError var parts: [String] = ["\(ns.domain)(\(ns.code))"] // At depth 0, always include the localised description so the user sees // a human-readable message even when userInfo has no other keys. if depth == 0, let msg = ns.userInfo[NSLocalizedDescriptionKey] as? String { parts.append(msg) } for (key, value) in ns.userInfo { let k = "\(key)" if k == NSLocalizedDescriptionKey || k == "NSLocalizedDescription" { continue } if let nestedError = value as? Error { parts.append("\(k):\(describeError(nestedError, depth: depth + 1))") } else if let dict = value as? [AnyHashable: Any], !dict.isEmpty { let pairs = dict.prefix(3).map { kk, vv -> String in if let e = vv as? Error { return "\(kk)→\(describeError(e, depth: depth + 1))" } return "\(kk)=\(vv)" } parts.append("\(k){\(pairs.joined(separator: ", "))}") } else if !(value is [AnyHashable: Any]) { parts.append("\(k)=\(value)") } } return parts.joined(separator: "\n") } /// Turns an opaque CloudKit error into a human-readable, actionable hint by /// digging into CKPartialErrorsByItemIDKey — the per-record failures hidden /// inside a CKErrorPartialFailure (code 2). The dominant underlying code /// tells us the real problem: schema not deployed (unknownItem/invalidArguments) /// vs. quota vs. transient network. static func hint(for error: Error) -> String? { let ns = error as NSError // Collect all leaf CKError codes across a possible partial-failure tree. var codes: [Int] = [] func collect(_ e: NSError) { if e.domain == "CKErrorDomain", e.code != 2 { codes.append(e.code) } if let partial = e.userInfo["CKPartialErrorsByItemIDKey"] as? [AnyHashable: Any] { for case let sub as NSError in partial.values { collect(sub) } } for value in e.userInfo.values { if let sub = value as? NSError, sub !== e { collect(sub) } } } collect(ns) // CKError codes: 14 = unknownItem, 12 = invalidArguments, 6 = partialFailure, // 25 = quotaExceeded, 4 = networkFailure/unavailable, 3 = networkUnavailable, // 15 = serverRejectedRequest, 26 = operationCancelled. if codes.contains(14) || codes.contains(12) || codes.contains(15) { return String(localized: "icloud_hint_schema") } if codes.contains(25) { return String(localized: "icloud_hint_quota") } if codes.contains(3) || codes.contains(4) { return String(localized: "icloud_hint_network") } if ns.code == 2 { return String(localized: "icloud_hint_partial_generic") } return nil } /// Full recursive walk of a CloudKit/Core Data error tree. Unlike `describeError` /// (a compact one-liner), this follows NSUnderlyingError chains and /// CKPartialErrorsByItemIDKey, and pulls the failing record TYPE out of any /// embedded CKRecord — which is what tells us WHICH entity CloudKit rejects. static func deepErrorReport(_ error: Error) -> String { var lines: [String] = [] var leafCodes = Set() var recordTypes = Set() var visited = 0 func walk(_ err: Error, indent: Int) { guard visited < 300 else { return } visited += 1 let ns = err as NSError let pad = String(repeating: " ", count: indent) lines.append("\(pad)\(ns.domain)(\(ns.code))") if ns.domain == "CKErrorDomain", ns.code != 2 { leafCodes.insert(ns.code) } let info = ns.userInfo if let reason = info[NSLocalizedFailureReasonErrorKey] as? String { lines.append("\(pad) reason: \(reason)") } if let desc = info[NSLocalizedDescriptionKey] as? String { lines.append("\(pad) desc: \(desc)") } if let retry = info["CKErrorRetryAfterKey"] { lines.append("\(pad) retryAfter: \(retry)") } for key in ["CKRecordChangedErrorServerRecordKey", "CKRecordChangedErrorClientRecordKey", "CKRecordChangedErrorAncestorRecordKey"] { if let rec = info[key] as? CKRecord { recordTypes.insert(rec.recordType) lines.append("\(pad) record: \(rec.recordType) [\(rec.recordID.recordName)]") } } if let partial = info["CKPartialErrorsByItemIDKey"] as? [AnyHashable: Any] { lines.append("\(pad) partialErrors: \(partial.count)") for (rid, value) in partial.prefix(25) { if let name = (rid as? CKRecord.ID)?.recordName { lines.append("\(pad) item: \(name)") } if let sub = value as? Error { walk(sub, indent: indent + 3) } } } if let underlying = info[NSUnderlyingErrorKey] as? Error { lines.append("\(pad) underlying:") walk(underlying, indent: indent + 2) } for (k, v) in info { let ks = "\(k)" if ks == NSUnderlyingErrorKey || ks == "CKPartialErrorsByItemIDKey" { continue } if let sub = v as? Error, (sub as NSError) !== ns { lines.append("\(pad) [\(ks)]:") walk(sub, indent: indent + 2) } } let keys = info.keys.map { "\($0)" }.sorted() if !keys.isEmpty { lines.append("\(pad) keys: \(keys.joined(separator: ", "))") } } walk(error, indent: 0) var summary = "" if !leafCodes.isEmpty { summary += "leafCKCodes: [\(leafCodes.sorted().map(String.init).joined(separator: ", "))]\n" } if !recordTypes.isEmpty { summary += "recordTypes: [\(recordTypes.sorted().joined(separator: ", "))]\n" } return summary + lines.joined(separator: "\n") } /// Local Core Data integrity probe: per-entity counts + records with nil id / /// nil createdAt / orphan relationships that can silently block a CloudKit export. func integrityReport() -> String { let ctx = viewContext var lines: [String] = [] let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal", "JournalEntry"] for name in entities { let count = (try? ctx.count(for: NSFetchRequest(entityName: name))) ?? -1 lines.append("\(name): \(count)") } func flag(_ label: String, _ n: Int) { if n > 0 { lines.append("⚠︎ \(label): \(n)") } } func fetch(_ name: String) -> [NSManagedObject] { (try? ctx.fetch(NSFetchRequest(entityName: name))) ?? [] } let sources = fetch("InvestmentSource") flag("sources nil id", sources.filter { $0.value(forKey: "id") == nil }.count) flag("sources nil createdAt", sources.filter { $0.value(forKey: "createdAt") == nil }.count) let snaps = fetch("Snapshot") flag("snapshots nil id", snaps.filter { $0.value(forKey: "id") == nil }.count) flag("snapshots nil createdAt", snaps.filter { $0.value(forKey: "createdAt") == nil }.count) flag("orphan snapshots (nil source)", snaps.filter { $0.value(forKey: "source") == nil }.count) let goals = fetch("Goal") flag("goals nil id", goals.filter { $0.value(forKey: "id") == nil }.count) flag("goals nil createdAt", goals.filter { $0.value(forKey: "createdAt") == nil }.count) return lines.joined(separator: "\n") } func forceReload() { viewContext.perform { [weak self] in self?.viewContext.refreshAllObjects() DispatchQueue.main.async { NotificationCenter.default.post(name: .cloudKitForceReload, object: nil) } } } /// Forces NSPersistentCloudKitContainer to export all local data to iCloud. /// /// Strategy: advance `createdAt` by 1 ms for every record. This guarantees /// a *real* value change that the SQLite persistent store will write as a /// persistent-history transaction — which is what NSPersistentCloudKitContainer /// needs to discover records and enqueue them for CloudKit export. /// /// Setting a property to the *same* value may be silently discarded by the /// SQLite layer (no SQL UPDATE issued → no history entry → nothing to export). func forceExportToiCloud(completion: @escaping (Int) -> Void) { guard Self.cloudKitEnabled else { completion(0); return } let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal"] let context = newBackgroundContext() context.perform { var totalTouched = 0 for entityName in entities { let request = NSFetchRequest(entityName: entityName) request.fetchBatchSize = 50 guard let objects = try? context.fetch(request) else { continue } for object in objects { // Advance createdAt by 1 ms → always a genuine value change. let t = (object.value(forKey: "createdAt") as? Date) ?? Date() object.setValue(t.addingTimeInterval(0.001), forKey: "createdAt") totalTouched += 1 } } if context.hasChanges { try? context.save() } DispatchQueue.main.async { completion(totalTouched) } } } /// Forces an immediate re-read of all Core Data objects from the persistent store. /// Call this when the app returns to the foreground so any iCloud changes made on /// other devices (while this device was inactive) are picked up right away. func refreshFromCloudKit() { guard Self.cloudKitEnabled else { return } viewContext.perform { [weak self] in self?.viewContext.refreshAllObjects() } } // MARK: - Widget Data Refresh /// When true, `refreshWidgetData()` becomes a no-op. Set during batch operations /// (e.g. CSV import of hundreds of snapshots) so we don't run a synchronous WAL /// checkpoint + widget reload per row — that hammered the main thread into an ANR. /// Call `refreshWidgetData()` once when the batch finishes. var suppressWidgetRefresh = false func refreshWidgetData() { if suppressWidgetRefresh { return } if #available(iOS 14.0, *) { if Self.appGroupEnabled { checkpointWAL() WidgetCenter.shared.reloadAllTimelines() } } // The paired watch reads the same data changes; every repository that // refreshes the widget wants the watch refreshed too. Task { @MainActor in WatchSyncService.shared.scheduleSync() } } /// Forces SQLite to checkpoint the WAL file, ensuring all changes are written to the main database file. /// This is necessary for the widget to see changes, as it opens the database read-only. private func checkpointWAL() { viewContext.performAndWait { if viewContext.hasChanges { try? viewContext.save() } viewContext.refreshAllObjects() } } } // MARK: - Shared Container for Widgets extension CoreDataStack { static var sharedStoreURL: URL? { guard appGroupEnabled else { let fallbackURL = NSPersistentContainer.defaultDirectoryURL() .appendingPathComponent("PortfolioJournal.sqlite") return fallbackURL } if let appGroupURL = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)? .appendingPathComponent("PortfolioJournal.sqlite") { return appGroupURL } let fallbackURL = NSPersistentContainer.defaultDirectoryURL() .appendingPathComponent("PortfolioJournal.sqlite") print("App Group unavailable for widgets; using default store URL: \(fallbackURL)") return fallbackURL } /// Creates a lightweight Core Data stack for widgets (read-only) static func createWidgetContainer() -> NSPersistentContainer { let container = NSPersistentContainer(name: "PortfolioJournal") guard let storeURL = sharedStoreURL else { fatalError("Unable to get shared store URL") } let description = NSPersistentStoreDescription(url: storeURL) description.isReadOnly = true description.shouldMigrateStoreAutomatically = true description.shouldInferMappingModelAutomatically = true container.persistentStoreDescriptions = [description] container.loadPersistentStores { _, error in if let error = error { print("Widget Core Data failed: \(error)") } } return container } }