Fix duplicados CloudKit cross-device: dedup por identidad lógica + bump build 68

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
This commit is contained in:
alexandrev-tibco
2026-07-22 15:56:41 +02:00
parent d87d9d3dd5
commit 20b8a32ea3
2 changed files with 180 additions and 10 deletions
+170
View File
@@ -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<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: - 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()