# 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()
observeRemoteChanges()
}
private func observeRemoteChanges() {
cloudKitSyncManager.startObserving { [weak self] in
self?.handleRemoteStoreChange()
}
}
private func handleRemoteStoreChange() {
let result = cloudKitSyncManager.processObservedRemoteChange(
modelContext: &modelContext,
modelContainer: modelContainer
)
Design.debugLog(
"Received remote store change #\(result.eventCount); " +
"rebuiltContext=\(result.didRebuildModelContext); reloading"
)
reloadEntities()
}
func refresh() {
reloadData()
}
func reloadData() {
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?
@State private var macPulseTask: Task?
private let macPulseIntervalSeconds: TimeInterval = 5
var body: some View {
// Ensure body observes remote reload increments.
let _ = store.dataRefreshVersion
ContentView(store: store)
.onAppear {
if scenePhase == .active {
startMacPulseLoopIfNeeded()
}
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
startMacPulseLoopIfNeeded()
scheduleCloudKitFallbackRefresh()
} else if newPhase == .background {
fallbackRefreshTask?.cancel()
stopMacPulseLoop()
}
}
}
private func scheduleCloudKitFallbackRefresh() {
let activationDate = Date()
fallbackRefreshTask?.cancel()
fallbackRefreshTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(8))
guard !Task.isCancelled else { return }
guard !store.hasReceivedRemoteChange(since: activationDate) else { return }
store.refresh()
}
}
private func startMacPulseLoopIfNeeded() {
guard store.supportsMacCloudKitImportPulseFallback else { return }
macPulseTask?.cancel()
macPulseTask = Task { @MainActor in
while !Task.isCancelled {
guard scenePhase == .active else { return }
store.forceCloudKitImportPulse(reason: "active_loop")
try? await Task.sleep(for: .seconds(macPulseIntervalSeconds))
}
}
}
private func stopMacPulseLoop() {
macPulseTask?.cancel()
macPulseTask = nil
}
}
```
## 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.