a8a9ad64f3
- 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
937 lines
42 KiB
Swift
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
|
|
}
|
|
}
|