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()