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
This commit is contained in:
@@ -11,10 +11,49 @@ enum DeduplicationService {
|
|||||||
static func run(context: ModelContext) {
|
static func run(context: ModelContext) {
|
||||||
dedupeSettings(context: context)
|
dedupeSettings(context: context)
|
||||||
dedupeTags(context: context)
|
dedupeTags(context: context)
|
||||||
|
dedupeDishes(context: context)
|
||||||
dedupeWeekPlans(context: context)
|
dedupeWeekPlans(context: context)
|
||||||
try? context.save()
|
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
|
/// Keep a single AppSettings: prefer one with onboarding completed, then
|
||||||
/// premium (never drop an entitlement marker), then lowest id.
|
/// premium (never drop an entitlement marker), then lowest id.
|
||||||
private static func dedupeSettings(context: ModelContext) {
|
private static func dedupeSettings(context: ModelContext) {
|
||||||
|
|||||||
Reference in New Issue
Block a user