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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user