Files
FamilyMealPlanner/MealMood/Services/DeduplicationService.swift
T
alexandrev-tibco 4f3664cdd4 2.0: deduplicar Dishes por id en DeduplicationService (determinista)
DeduplicationService colapsaba settings, tags y week plans pero NO los
platos, dejando duplicados de CloudKit (mismo UUID) que causaban el crash
de arranque y platos repetidos en pantalla. Se añade dedupeDishes:

- Solo agrupa y colapsa copias que comparten el mismo id (nunca fusiona
  platos distintos ni toca platos únicos).
- Keeper elegido de forma DETERMINISTA (copia más rica: foto/desc/
  ingredientes/tags/prioridad; desempate por persistentModelID estable),
  así todos los dispositivos de la cuenta borran los mismos perdedores y
  el grafo converge a una copia, nunca a cero.
- CloudKit private DB por Apple ID (sin CKShare) → jamás cruza cuentas de
  Family Sharing.

Junto con el uniquingKeysWith previo, la app deja de crashear y limpia los
platos duplicados en cada arranque, sin borrar la BD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3HaWmmtTQ1vdTERtSYU6p
2026-07-22 20:39:07 +02:00

123 lines
5.6 KiB
Swift

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<Dish>())) ?? []
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<AppSettings>())) ?? []
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<Tag>())) ?? []
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<Dish>())) ?? []
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<WeekPlan>())) ?? []
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)
}
}
}
}