From 20b8a32ea33eb5723bf5f6bb59f6d4a38f8ea4b8 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Wed, 22 Jul 2026 15:56:41 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20duplicados=20CloudKit=20cross-device:=20d?= =?UTF-8?q?edup=20por=20identidad=20l=C3=B3gica=20+=20bump=20build=2068?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cleanupLogicalDuplicates() colapsa registros con misma clave de negocio pero distinto UUID (defaults sembrados por-dispositivo antes del primer import): Account/Category por name, InvestmentSource por account+name, Snapshot por source+día+valor, Goal por name+target, JournalEntry por monthKey. Ganador determinista (createdAt asc, tiebreak UUID) → todos los dispositivos eligen el mismo superviviente y los deletes convergen sin race. Reasigna hijos al ganador antes de borrar. Snapshot dedup produce la UNIÓN → sin pérdida de historial. Cableado en processRemoteChanges (tras import) + carga inicial (limpia lo ya existente). El dedup previo por UUID no veía estos casos. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7 --- PortfolioJournal.xcodeproj/project.pbxproj | 20 +-- PortfolioJournal/Models/CoreDataStack.swift | 170 ++++++++++++++++++++ 2 files changed, 180 insertions(+), 10 deletions(-) diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index 4dbfbb6..1445d06 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements; ENABLE_USER_SCRIPT_SANDBOXING = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -582,7 +582,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal"; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -739,7 +739,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -774,7 +774,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget"; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -805,7 +805,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -829,7 +829,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -852,7 +852,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -875,7 +875,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -899,7 +899,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist; @@ -927,7 +927,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = 2825Q76T7H; PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore"; GENERATE_INFOPLIST_FILE = NO; diff --git a/PortfolioJournal/Models/CoreDataStack.swift b/PortfolioJournal/Models/CoreDataStack.swift index a9749c9..e4ab70b 100644 --- a/PortfolioJournal/Models/CoreDataStack.swift +++ b/PortfolioJournal/Models/CoreDataStack.swift @@ -213,6 +213,11 @@ class CoreDataStack: ObservableObject { } DispatchQueue.main.async { self?.isLoaded = true + // Collapse any logical duplicates already sitting in the local store + // (per-device default accounts/categories piled up by earlier syncs). + // Runs once at launch so an existing mess is cleaned even if no new + // remote change arrives this session. + self?.cleanupLogicalDuplicates() MonthlyCheckInStore.migrateIfNeeded() // Migrate device-local UserDefaults data into Core Data so it syncs via iCloud. MonthlyContributionStore.migrateIfNeeded(context: container.viewContext) @@ -303,6 +308,163 @@ class CoreDataStack: ObservableObject { 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: - Save Context func save() { @@ -344,6 +506,14 @@ class CoreDataStack: ObservableObject { if removed > 0 { print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import") } + // Then collapse *logical* duplicates (same entity, different UUIDs) — + // e.g. per-device default accounts/categories, or a dataset that was + // seeded independently in two environments. UUID dedup above can't see + // these because the UUIDs differ. + let logical = self.cleanupLogicalDuplicates() + if logical > 0 { + print("[RemoteChanges] Removed \(logical) logical duplicate objects after CloudKit import") + } // Notify repositories to re-fetch unconditionally. NotificationCenter.default.post(name: .cloudKitForceReload, object: nil) self.objectWillChange.send()