1.6.0 #3: limpieza CloudKit-safe de duplicados de defaults (merge + Nullify)
Reglas de borrado Account.sources/goals y Category.sources: Cascade -> Nullify. Así un borrado de padre (local o propagado por CloudKit al importar) NUNCA arrasa hijos — elimina de raíz el mecanismo del desastre del build 68. Además es mejor UX (borrar una categoría ya no borra todas sus sources+snapshots). CoreDataStack.mergeDefaultDuplicates(): fusiona los Account 'Default' duplicados y las categorías duplicadas por nombre en un ganador DETERMINISTA (el registro con el UUID estable si existe, si no earliest createdAt / menor UUID → todos los dispositivos convergen). Reasigna sources/goals al ganador y borra los perdedores (seguro por Nullify). Llamado en la carga inicial y tras cada import remoto. 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,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<NSManagedObject> else { return }
|
||||
for child in Array(set) { child.setValue(winner, forKey: key) }
|
||||
}
|
||||
|
||||
private func mergeDefaultAccounts(in context: NSManagedObjectContext) -> Int {
|
||||
let request = NSFetchRequest<NSManagedObject>(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<NSManagedObject>(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()
|
||||
|
||||
Reference in New Issue
Block a user