2.0: CloudKit private-database sync replaces KV snapshot sync
Foundation for 2.1 family sharing (CKShare needs these models + container). SwiftData cannot share across Apple IDs today, so 2.0 ships true multi-device sync for the same account instead: - Models made CloudKit-compatible: dropped @Attribute(.unique) on all 5, inline defaults on every attribute, WeekPlan.slots stored as optional relationship (name preserved → lightweight migration) with non-optional slotList facade; ~73 call sites renamed. - Container: cloudKitDatabase .automatic, falling back to the local-only store when CloudKit is unavailable; failures recorded to Crashlytics. - Entitlements: iCloud CloudKit service (container already existed); remote-notification background mode for push-driven sync. - Legacy KV snapshot sync (ICloudSyncService) stays inert when CloudKit is active — kept only for 1.x devices. - DeduplicationService collapses cross-device duplicates deterministically on launch (settings singleton, default tags with tagId remapping, same-week plans). - Note: AppSettings.isPremium now syncs across same-Apple-ID devices; StoreKit (PremiumSyncService + Transaction.updates) remains the source of truth and reconciles on every launch/foreground. All 21 unit tests pass, including ICloudSyncPremiumIsolationTests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
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)
|
||||
dedupeWeekPlans(context: context)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user