# 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: SwiftDataCloudKitStore { @ObservationIgnored let modelContainer: ModelContainer @ObservationIgnored var modelContext: ModelContext @ObservationIgnored let cloudKitSyncManager = SwiftDataCloudKitSyncManager( isEnabled: true, logIdentifier: "AppCloudKitSync" ) private(set) var entities: [PrimaryEntity] = [] private(set) var dataRefreshVersion: Int = 0 init(modelContext: ModelContext) { self.modelContainer = modelContext.container self.modelContext = modelContext reloadEntities() startObservingCloudKitRemoteChanges() } func refresh() { reloadData() } func reloadData() { reloadEntities() } func updateEntity(_ entity: PrimaryEntity) { // mutate entity fields... saveAndReload() } func didSaveAndReloadData() { // optional post-save side effects } func handleSaveAndReloadError(_ error: Error) { Design.debugLog("AppDataStore: save failed: \(error.localizedDescription)") } 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 cloudKitSceneSyncCoordinator = SwiftDataCloudKitSceneSyncCoordinator( configuration: .init(logIdentifier: "RootView.CloudKitSync") ) var body: some View { // Ensure body observes remote reload increments. let _ = store.dataRefreshVersion ContentView(store: store) .onAppear { if scenePhase == .active { cloudKitSceneSyncCoordinator.handleDidBecomeActive( store: store, isAppActive: { scenePhase == .active }, refresh: { store.refresh() } ) } } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { cloudKitSceneSyncCoordinator.handleDidBecomeActive( store: store, isAppActive: { scenePhase == .active }, refresh: { store.refresh() } ) } else if newPhase == .background { cloudKitSceneSyncCoordinator.handleDidEnterBackground() } } } } ``` ## 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.