diff --git a/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents b/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents index 1b72142..50cf469 100644 --- a/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents +++ b/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents @@ -21,8 +21,8 @@ - - + + @@ -32,7 +32,7 @@ - + diff --git a/PortfolioJournal/Models/CoreDataStack.swift b/PortfolioJournal/Models/CoreDataStack.swift index 7d254ee..a2f3a26 100644 --- a/PortfolioJournal/Models/CoreDataStack.swift +++ b/PortfolioJournal/Models/CoreDataStack.swift @@ -213,12 +213,11 @@ class CoreDataStack: ObservableObject { } DispatchQueue.main.async { self?.isLoaded = true - // NOTE: automatic logical dedup DISABLED. Deleting cascade-parent - // entities (Account/Category) and letting those deletes propagate via - // CloudKit can trigger the Cascade delete rule on OTHER devices before - // the child-reassignment transaction imports — cascade-wiping sources - // and snapshots. This caused total data loss across devices on build 68. - // Dedup must be redesigned to be CloudKit-safe before re-enabling. + // 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) @@ -466,6 +465,86 @@ class CoreDataStack: ObservableObject { 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() { @@ -507,9 +586,12 @@ class CoreDataStack: ObservableObject { if removed > 0 { print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import") } - // NOTE: automatic logical dedup DISABLED here on purpose — see the load - // block. Deleting cascade-parent entities and propagating those deletes - // via CloudKit cascade-wiped children on other devices (build 68 incident). + // 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()