import Foundation import SwiftData /// CloudKit private-DB sync can materialize duplicates when several devices /// independently create "the same" entity before their first sync converges: /// the AppSettings singleton, the default Tags, or the plan for a given week. /// This runs once per launch and collapses them deterministically, so every /// device deletes the same losers and the graph converges. enum DeduplicationService { static func run(context: ModelContext) { dedupeSettings(context: context) dedupeTags(context: context) dedupeDishes(context: context) dedupeWeekPlans(context: context) try? context.save() } /// Collapse Dishes that CloudKit mirrored into several objects sharing the /// same `id` UUID (the cause of the "Duplicate values for key" launch /// crash). Only ever touches copies that share one id — distinct dishes are /// never merged. The keeper is chosen **deterministically** (richest copy, /// then a stable store-identity tiebreak) so every device on the account /// deletes the exact same losers and the graph converges to a single copy — /// never to zero. Slot references point at the id, which is unchanged, so /// no remapping is needed. private static func dedupeDishes(context: ModelContext) { let all = (try? context.fetch(FetchDescriptor())) ?? [] guard all.count > 1 else { return } let grouped = Dictionary(grouping: all, by: \.id) for (_, copies) in grouped where copies.count > 1 { let keeper = copies.sorted { lhs, rhs in let lhsRichness = richness(of: lhs) let rhsRichness = richness(of: rhs) if lhsRichness != rhsRichness { return lhsRichness > rhsRichness } return String(describing: lhs.persistentModelID) < String(describing: rhs.persistentModelID) }.first! for copy in copies where copy !== keeper { context.delete(copy) } } } /// Rough "how much data does this copy carry" score, used to pick which /// duplicate to keep so a richer copy never loses to an emptier one. private static func richness(of dish: Dish) -> Int { var score = 0 if dish.photoData != nil { score += 4 } if !(dish.descriptionText ?? "").isEmpty { score += 2 } if dish.isPriority { score += 1 } score += dish.ingredients.count score += dish.tagIds.count return score } /// Keep a single AppSettings: prefer one with onboarding completed, then /// premium (never drop an entitlement marker), then lowest id. private static func dedupeSettings(context: ModelContext) { let all = (try? context.fetch(FetchDescriptor())) ?? [] guard all.count > 1 else { return } let keeper = all.sorted { lhs, rhs in if lhs.onboardingCompleted != rhs.onboardingCompleted { return lhs.onboardingCompleted } if lhs.isPremium != rhs.isPremium { return lhs.isPremium } return lhs.persistentModelID.hashValue < rhs.persistentModelID.hashValue }.first! for candidate in all where candidate !== keeper { context.delete(candidate) } } /// Collapse Tags duplicated by id, and default Tags duplicated by English /// name (two devices seeding the same defaults with different UUIDs). /// Dish.tagIds referencing a removed duplicate are remapped to the keeper. private static func dedupeTags(context: ModelContext) { let all = (try? context.fetch(FetchDescriptor())) ?? [] guard all.count > 1 else { return } var idMap: [UUID: UUID] = [:] // loser id → keeper id var seen: [String: Tag] = [:] for tag in all.sorted(by: { $0.id.uuidString < $1.id.uuidString }) { let key = tag.isDefault ? "default|\(tag.nameEN.lowercased())" : "id|\(tag.id.uuidString)" if let keeper = seen[key] { if keeper.id != tag.id { idMap[tag.id] = keeper.id } context.delete(tag) } else { seen[key] = tag } } guard !idMap.isEmpty else { return } let dishes = (try? context.fetch(FetchDescriptor())) ?? [] for dish in dishes { let remapped = dish.tagIds.map { idMap[$0] ?? $0 } let unique = Array(NSOrderedSet(array: remapped)) as? [UUID] ?? remapped if unique != dish.tagIds { dish.tagIds = unique } } } /// Keep one WeekPlan per weekStartDate: the one with most assigned slots /// (ties: more slots, then lowest id). Losers cascade-delete their slots. private static func dedupeWeekPlans(context: ModelContext) { let all = (try? context.fetch(FetchDescriptor())) ?? [] let grouped = Dictionary(grouping: all, by: \.weekStartDate) for (_, plans) in grouped where plans.count > 1 { let keeper = plans.sorted { lhs, rhs in let lhsAssigned = lhs.slotList.filter { $0.dishId != nil }.count let rhsAssigned = rhs.slotList.filter { $0.dishId != nil }.count if lhsAssigned != rhsAssigned { return lhsAssigned > rhsAssigned } if lhs.slotList.count != rhs.slotList.count { return lhs.slotList.count > rhs.slotList.count } return lhs.id.uuidString < rhs.id.uuidString }.first! for plan in plans where plan !== keeper { context.delete(plan) } } } }