Files
InvestmentTrackerApp/PortfolioJournal/Models/CoreDataStack.swift
T
alexandrev-tibco a8a9ad64f3 Xcode 27: compila sin warnings y migra API deprecadas de SwiftUI
- Imports de CoreData que faltaban (MemberImportVisibility) en AccountsView,
  ImportDataView, ChartsContainerView y AddSourceView.
- Aislamiento MainActor: IsolatedDefaultValues en el target de la app,
  LinearGradient @MainActor, WatchSyncMessage nonisolated, closures de
  ReviewPromptService/MonthlyCheckInStore/AppIntents.
- Warnings de valores sin usar (SnapshotGapDetector, DashboardLayoutStore,
  OnboardingView, GoalEditorView, CoreDataStack, SettingsViewModel).
- foregroundColor→foregroundStyle, cornerRadius→clipShape,
  navigationBarLeading/Trailing→topBarLeading/Trailing,
  Task.sleep(nanoseconds:)→sleep(for:), NavigationLink(isActive:)→
  navigationDestination(isPresented:).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 11:44:49 +02:00

937 lines
42 KiB
Swift

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<NSManagedObject>(entityName: "Snapshot")
snapshotRequest.resultType = .countResultType
let snapshotCount = (try? context.count(for: snapshotRequest)) ?? 0
if snapshotCount > 0 {
return true
}
let sourceRequest = NSFetchRequest<NSManagedObject>(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<NSManagedObject>(entityName: "InvestmentSource")
return (try? viewContext.count(for: request)) ?? 0
}
var localSnapshotCount: Int {
let request = NSFetchRequest<NSManagedObject>(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<NSManagedObject>(entityName: entityName)
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
guard let objects = try? context.fetch(request) else { return 0 }
var seenIds = Set<UUID>()
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<NSManagedObject>(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<NSManagedObject> else { return }
for child in Array(set) { child.setValue(winner, forKey: key) }
}
private func mergeDefaultAccounts(in context: NSManagedObjectContext) -> Int {
let request = NSFetchRequest<NSManagedObject>(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<NSManagedObject>(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<Int>()
var recordTypes = Set<String>()
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<NSManagedObject>(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<NSManagedObject>(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<NSManagedObject>(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
}
}