# SwiftData to CloudKit Sync Template (Copy/Paste) Use this as a starter for new apps that need near-real-time SwiftData sync across Apple devices. ## 1) Build Config (xcconfig) ```xcconfig // Debug.xcconfig #include "Base.xcconfig" APS_ENVIRONMENT = development // Release.xcconfig #include "Base.xcconfig" APS_ENVIRONMENT = production ``` Entitlements should include: ```xml aps-environment $(APS_ENVIRONMENT) ``` ## 2) App Container Setup ```swift import SwiftData enum AppIdentifiers { static let appGroupIdentifier = "group.com.example.app" static let cloudKitContainerIdentifier = "iCloud.com.example.app" } func makeModelContainer(isRunningTests: Bool) throws -> ModelContainer { let schema = Schema([ Ritual.self, RitualArc.self, ArcHabit.self ]) let storeURL = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)? .appendingPathComponent("App.sqlite") ?? URL.documentsDirectory.appendingPathComponent("App.sqlite") let config = ModelConfiguration( schema: schema, url: storeURL, cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier) ) return try ModelContainer(for: schema, configurations: [config]) } ``` ## 3) Store Pattern (Remote Change + Context Refresh) ```swift import Foundation import Observation import SwiftData import CoreData import os @MainActor @Observable final class RitualStore { @ObservationIgnored private let modelContainer: ModelContainer @ObservationIgnored private var modelContext: ModelContext @ObservationIgnored private var remoteChangeObserver: NSObjectProtocol? @ObservationIgnored private let syncLogger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "App", category: "CloudKitSync" ) private(set) var rituals: [Ritual] = [] private(set) var dataRefreshVersion: Int = 0 private(set) var lastRemoteChangeDate: Date? private(set) var remoteChangeEventCount: Int = 0 init(modelContext: ModelContext) { self.modelContainer = modelContext.container self.modelContext = modelContext reloadRituals() observeRemoteChanges() } deinit { if let observer = remoteChangeObserver { NotificationCenter.default.removeObserver(observer) } } private func observeRemoteChanges() { syncLogger.info("Starting CloudKit remote change observation") remoteChangeObserver = NotificationCenter.default.addObserver( forName: .NSPersistentStoreRemoteChange, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in self?.handleRemoteStoreChange() } } } private func handleRemoteStoreChange() { remoteChangeEventCount += 1 lastRemoteChangeDate = Date() // Important when long-lived contexts become stale after remote merges. modelContext = ModelContext(modelContainer) syncLogger.info( "Received remote store change #\(self.remoteChangeEventCount, privacy: .public); reloading" ) reloadRituals() } func refresh() { reloadRituals() } private func reloadRituals() { do { rituals = try modelContext.fetch(FetchDescriptor()) dataRefreshVersion &+= 1 } catch { syncLogger.error("Reload failed: \(error.localizedDescription, privacy: .public)") } } } ``` ## 4) Root View Pattern (Safety-Net Refresh) ```swift import SwiftUI struct RootView: View { @Bindable var store: RitualStore @Environment(\.scenePhase) private var scenePhase @State private var fallbackRefreshTask: Task? var body: some View { // Ensure body observes remote reload increments. let _ = store.dataRefreshVersion ContentView(store: store) .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { scheduleCloudKitFallbackRefresh() } else if newPhase == .background { fallbackRefreshTask?.cancel() } } } private func scheduleCloudKitFallbackRefresh() { let activationDate = Date() fallbackRefreshTask?.cancel() fallbackRefreshTask = Task { @MainActor in try? await Task.sleep(for: .seconds(8)) guard !Task.isCancelled else { return } let gotRemoteSinceActive = store.lastRemoteChangeDate.map { $0 >= activationDate } ?? false guard !gotRemoteSinceActive else { return } store.refresh() } } } ``` ## 5) Verification Script (Manual) Run this release gate on two real devices: 1. Single habit toggle on Device A appears on Device B with both open. 2. Rapid 4-6 toggles on Device A all appear on Device B without pull-to-refresh. 3. Background Device B, then foreground, data remains consistent. 4. Device logs show `CloudKitSync` remote-change events. 5. CloudKit Console private DB shows matching record updates. ## 6) Common Failure Modes - Push works but UI stale: context/view invalidation issue. - Only updates after relaunch: missing remote observer or stale long-lived context. - Works in one build flavor only: `aps-environment` mismatch or signing/provisioning drift. - Partial batch updates shown: render invalidation not happening for every remote merge.