Andromida/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md

5.2 KiB

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)

// Debug.xcconfig
#include "Base.xcconfig"
APS_ENVIRONMENT = development

// Release.xcconfig
#include "Base.xcconfig"
APS_ENVIRONMENT = production

Entitlements should include:

<key>aps-environment</key>
<string>$(APS_ENVIRONMENT)</string>

2) App Container Setup

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)

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<PrimaryEntity>())
            dataRefreshVersion &+= 1
        } catch {
            Design.debugLog("AppDataStore: reload failed: \(error.localizedDescription)")
        }
    }
}

4) Root View Pattern (Safety-Net Refresh)

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.