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:
alexandrev-tibco
2026-07-25 12:18:42 +02:00
parent 922710c53f
commit fd1094b4fc
2 changed files with 94 additions and 12 deletions
@@ -21,8 +21,8 @@
<attribute name="name" attributeType="String" defaultValueString=""/> <attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/> <attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/> <relationship name="goals" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/> <relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
</entity> </entity>
<entity name="Category" representedClassName="Category" syncable="YES"> <entity name="Category" representedClassName="Category" syncable="YES">
<attribute name="allocationTarget" optional="YES" attributeType="Double" usesScalarValueType="NO"/> <attribute name="allocationTarget" optional="YES" attributeType="Double" usesScalarValueType="NO"/>
@@ -32,7 +32,7 @@
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/> <attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/> <relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
</entity> </entity>
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES"> <entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
+91 -9
View File
@@ -213,12 +213,11 @@ class CoreDataStack: ObservableObject {
} }
DispatchQueue.main.async { DispatchQueue.main.async {
self?.isLoaded = true self?.isLoaded = true
// NOTE: automatic logical dedup DISABLED. Deleting cascade-parent // Merge the pre-stable-UUID default duplicates. Safe now: the merge
// entities (Account/Category) and letting those deletes propagate via // reassigns children then deletes losers, and the relevant relationships
// CloudKit can trigger the Cascade delete rule on OTHER devices before // use the Nullify delete rule so a propagated parent-delete can never
// the child-reassignment transaction imports cascade-wiping sources // cascade-wipe children (unlike the build-68 Cascade disaster).
// and snapshots. This caused total data loss across devices on build 68. self?.mergeDefaultDuplicates()
// Dedup must be redesigned to be CloudKit-safe before re-enabling.
MonthlyCheckInStore.migrateIfNeeded() MonthlyCheckInStore.migrateIfNeeded()
// Migrate device-local UserDefaults data into Core Data so it syncs via iCloud. // Migrate device-local UserDefaults data into Core Data so it syncs via iCloud.
MonthlyContributionStore.migrateIfNeeded(context: container.viewContext) MonthlyContributionStore.migrateIfNeeded(context: container.viewContext)
@@ -466,6 +465,86 @@ class CoreDataStack: ObservableObject {
return removed 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 // MARK: - Save Context
func save() { func save() {
@@ -507,9 +586,12 @@ class CoreDataStack: ObservableObject {
if removed > 0 { if removed > 0 {
print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import") print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import")
} }
// NOTE: automatic logical dedup DISABLED here on purpose see the load // Merge default duplicates after each import (CloudKit-safe via Nullify
// block. Deleting cascade-parent entities and propagating those deletes // rules + deterministic winner devices converge, no cascade wipes).
// via CloudKit cascade-wiped children on other devices (build 68 incident). let mergedDefaults = self.mergeDefaultDuplicates()
if mergedDefaults > 0 {
print("[RemoteChanges] Merged \(mergedDefaults) default duplicates after CloudKit import")
}
// Notify repositories to re-fetch unconditionally. // Notify repositories to re-fetch unconditionally.
NotificationCenter.default.post(name: .cloudKitForceReload, object: nil) NotificationCenter.default.post(name: .cloudKitForceReload, object: nil)
self.objectWillChange.send() self.objectWillChange.send()