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:
alexandrev-tibco
2026-07-12 18:13:45 +02:00
parent e15ff93465
commit 4ffa15b06a
28 changed files with 238 additions and 98 deletions
+10 -10
View File
@@ -34,7 +34,7 @@ struct AutocompleteEngine {
var pendingSlots = emptySlots.filter { !$0.isEatingOut }
// MRV: at each step pick the slot with fewest valid candidates first.
// Assignments update currentPlan.slots in-place, so subsequent candidate
// Assignments update currentPlan.slotList in-place, so subsequent candidate
// counts automatically reflect the growing set of committed dishes.
while !pendingSlots.isEmpty {
// Score each remaining slot by strict candidate count
@@ -78,7 +78,7 @@ struct AutocompleteEngine {
static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] {
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
return plan.slots.compactMap { slot in
return plan.slotList.compactMap { slot in
guard let dishId = slot.dishId,
let dish = dishMap[dishId],
slot.isRuleOverridden,
@@ -119,7 +119,7 @@ struct AutocompleteEngine {
}
private static func violatesDefaultNoRepeatRule(dish: Dish, slot: MealSlot, plan: WeekPlan) -> Bool {
plan.slots.contains { $0.id != slot.id && $0.dishId == dish.id }
plan.slotList.contains { $0.id != slot.id && $0.dishId == dish.id }
}
private static func violatesExplicitRules(
@@ -134,7 +134,7 @@ struct AutocompleteEngine {
if let maxPerWeek = tag.maxPerWeek {
var count = 0
for s in plan.slots {
for s in plan.slotList {
guard let did = s.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { count += 1 }
}
@@ -149,7 +149,7 @@ struct AutocompleteEngine {
}
if tag.noDuplicateInDay {
for sdSlot in plan.slots where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id {
for sdSlot in plan.slotList where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id {
guard let did = sdSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { return true }
}
@@ -173,11 +173,11 @@ struct AutocompleteEngine {
private static func adjacentSlots(of slot: MealSlot, in plan: WeekPlan) -> [MealSlot] {
var result: [MealSlot] = []
if slot.dayOfWeek > 0,
let prev = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) {
let prev = plan.slotList.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) {
result.append(prev)
}
if slot.dayOfWeek < 6,
let next = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) {
let next = plan.slotList.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) {
result.append(next)
}
return result
@@ -201,7 +201,7 @@ struct AutocompleteEngine {
// Week offsets: how many weeks ago did this dish appear?
var offsets: [Int] = []
for plan in recentPlans {
guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue }
guard plan.slotList.contains(where: { $0.dishId == dish.id }) else { continue }
let diff = calendar.dateComponents([.weekOfYear], from: plan.weekStartDate, to: currentWeekStart).weekOfYear ?? 0
if diff > 0 { offsets.append(diff) }
}
@@ -261,7 +261,7 @@ struct AutocompleteEngine {
for dish in dishes {
var liked = 0, disliked = 0
for plan in ratedPlans {
guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue }
guard plan.slotList.contains(where: { $0.dishId == dish.id }) else { continue }
if plan.userRating > 0 { liked += 1 } else { disliked += 1 }
}
guard liked + disliked > 0 else { continue }
@@ -283,7 +283,7 @@ struct AutocompleteEngine {
) -> Dish? {
guard !candidates.isEmpty else { return nil }
let weights: [Double] = candidates.map { dish in
let usage = plan.slots.filter { $0.dishId == dish.id }.count
let usage = plan.slotList.filter { $0.dishId == dish.id }.count
let priorityBoost: Double = dish.isPriority ? 2.5 : 1.0
let patternScore = historyScores[dish.id] ?? 1.0
let ratingScore = ratingScores[dish.id] ?? 1.0