# SwiftData to CloudKit Sync Template (Copy/Paste) Primary source of truth lives in Bedrock: `/Users/mattbruce/Documents/Projects/iPhone/Andromida/Bedrock/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md` This file is an app-local quick reference and should mirror the Bedrock setup guide. 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 (Bedrock Manager + Context Refresh) ```swift import Foundation import Observation import SwiftData import Bedrock @MainActor @Observable final class AppDataStore { @ObservationIgnored private let modelContainer: ModelContainer @ObservationIgnored private var modelContext: ModelContext @ObservationIgnored private let cloudKitSyncManager = SwiftDataCloudKitSyncManager( isEnabled: true, logIdentifier: "AppCloudKitSync" ) private(set) var entities: [PrimaryEntity] = [] private(set) var dataRefreshVersion: Int = 0 private(set) var lastRemoteChangeDate: Date? init(modelContext: ModelContext) { self.modelContainer = modelContext.container self.modelContext = modelContext reloadEntities() observeRemoteChanges() } private func observeRemoteChanges() { cloudKitSyncManager.startObserving { [weak self] in self?.handleRemoteStoreChange() } } private func handleRemoteStoreChange() { lastRemoteChangeDate = cloudKitSyncManager.lastRemoteChangeDate let eventCount = cloudKitSyncManager.remoteChangeEventCount // Important when long-lived contexts become stale after remote merges. modelContext = ModelContext(modelContainer) Design.debugLog("Received remote store change #\(eventCount); reloading") reloadEntities() } func refresh() { reloadEntities() } private func reloadEntities() { do { entities = try modelContext.fetch(FetchDescriptor()) dataRefreshVersion &+= 1 } catch { Design.debugLog("AppDataStore: reload failed: \(error.localizedDescription)") } } } ``` ## 4) Root View Pattern (Safety-Net Refresh) ```swift import SwiftUI struct RootView: View { @Bindable var store: AppDataStore @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.