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