Learning CloudKit: The Complete iOS Developer's Guide

From First Record to Production-Grade Sync Engine

Target: Intermediate Swift developers building their first CloudKit-powered app iOS Version: iOS 17+ / Swift 5.9+ / Xcode 15+ App We Build: CloudJournal — a journaling app with images, categories, sharing, and offline sync


Table of Contents

Part 1 — Foundation

  1. What is CloudKit & Why Use It
  2. CloudKit Architecture Deep Dive
  3. Project Setup — Xcode, Entitlements & Dashboard
  4. iCloud Account Status & Availability

Part 2 — Core Operations (Building CRUD)

  1. Records, Zones, and Identifiers
  2. Creating and Saving Records
  3. Fetching and Querying Records
  4. Updating Records & Save Policies
  5. Deleting Records
  6. Batch Operations & Atomicity

Part 3 — Intermediate Topics

  1. Custom Record Zones & Why They Matter
  2. Binary Data with CKAsset
  3. Relationships with CKRecord.Reference
  4. The Public Database
  5. Subscriptions & Push Notifications
  6. Incremental Sync with Server Change Tokens

Part 4 — Advanced Topics

  1. CKSyncEngine — The Modern Sync Architecture (iOS 17+)
  2. Encrypted Fields (encryptedValues)
  3. CloudKit Sharing with CKShare
  4. Conflict Resolution Strategies
  5. Offline-First Architecture with SwiftData
  6. Error Handling & Retry Patterns
  7. Performance Optimization
  8. Testing CloudKit Apps
  9. SwiftData + CloudKit Automatic Sync
  10. Production Deployment & Schema Management

Appendices


Part 1 — Foundation


1. What is CloudKit & Why Use It

The 30-Second Pitch

CloudKit is Apple's cloud backend framework. It stores structured data (records) and binary files (assets) in iCloud and syncs them across a user's devices. You get a database, file storage, push notifications, and user authentication — all without running your own server.

When to Choose CloudKit

CloudKit is the right choice when:

When NOT to Choose CloudKit

CloudKit vs. Alternatives

Feature CloudKit Firebase Firestore Supabase
Cost Free (private DB uses user's iCloud) Pay per read/write Pay per usage
Platform Apple only Cross-platform Cross-platform
Auth Automatic (iCloud account) Multiple providers Multiple providers
Offline sync Manual (or CKSyncEngine) Built-in Manual
Server-side logic None Cloud Functions Edge Functions
Privacy End-to-end encrypted (private DB) Google servers Self-hosted possible
SQL-like queries NSPredicate (limited) Firestore queries Full PostgreSQL

What We're Building: CloudJournal

Throughout this guide, we'll build CloudJournal — a journaling app with these features:

Here's the data model we'll build toward:

┌─────────────────────────────────────────────────────────┐
│                   CloudJournal Schema                    │
│                                                          │
│  Category ←───── JournalEntry ←───── EntryImage          │
│  (name,          (title, body,       (imageAsset,        │
│   colorHex,       mood, isFavorite,   caption,           │
│   icon)           createdAt,          sortOrder)          │
│                   modifiedAt,                             │
│                   category ref)                           │
└─────────────────────────────────────────────────────────┘

2. CloudKit Architecture Deep Dive

The Container → Database → Zone → Record Hierarchy

Every CloudKit app operates within a strict hierarchy. Understanding this hierarchy is fundamental to everything else.

┌─────────────────────────────────────────────────────────────────┐
│  CKContainer (iCloud.com.yourcompany.cloudjournal)              │
│                                                                  │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐    │
│  │  Private DB     │  │  Public DB     │  │  Shared DB     │    │
│  │                 │  │                │  │                │    │
│  │  ┌───────────┐ │  │  ┌──────────┐  │  │  ┌──────────┐  │    │
│  │  │ Default   │ │  │  │ Default  │  │  │  │ Zone per │  │    │
│  │  │ Zone      │ │  │  │ Zone     │  │  │  │ share    │  │    │
│  │  └───────────┘ │  │  │ (only    │  │  │  │ owner    │  │    │
│  │  ┌───────────┐ │  │  │  zone)   │  │  │  └──────────┘  │    │
│  │  │ Custom    │ │  │  └──────────┘  │  │  ┌──────────┐  │    │
│  │  │ Zone(s)   │ │  │                │  │  │ Zone per │  │    │
│  │  │           │ │  │                │  │  │ share    │  │    │
│  │  │ CKRecord  │ │  │  CKRecord      │  │  │ owner    │  │    │
│  │  │ CKRecord  │ │  │  CKRecord      │  │  └──────────┘  │    │
│  │  │ CKRecord  │ │  │  CKRecord      │  │                │    │
│  │  └───────────┘ │  │                │  │                │    │
│  └────────────────┘  └────────────────┘  └────────────────┘    │
└─────────────────────────────────────────────────────────────────┘

CKContainer — Your App's Sandbox

A CKContainer is the top-level enclosure for all your CloudKit data. Think of it as your app's entire cloud storage namespace.

Key facts: - Identified by a reverse-DNS string: iCloud.com.yourcompany.appname - Every app gets a default container matching its bundle identifier - You can create additional containers (useful for sharing data between apps) - The container identifier is permanent — you cannot change it after shipping - Access via CKContainer.default() or CKContainer(identifier: "iCloud.com.yourcompany.cloudjournal")

// Default container (matches your bundle ID)
let defaultContainer = CKContainer.default()

// Explicit container (recommended — explicit is better than implicit)
let container = CKContainer(identifier: "iCloud.com.yourcompany.cloudjournal")

CKDatabase — Three Databases Per Container

Each container holds exactly three databases. You cannot create additional databases.

Private Database (container.privateCloudDatabase) - Stores the current user's personal data - Only the owning user can read or write - Data counts against the user's iCloud storage quota (5 GB free, or whatever their plan provides) - Supports custom record zones (critical for sync) - Requires an iCloud account to be signed in

Public Database (container.publicCloudDatabase) - Readable by anyone, even users without an iCloud account - Writable only by authenticated users (signed into iCloud) - Data counts against your app's CloudKit quota (Apple provides generous free tiers) - Does NOT support custom record zones — only the default zone - Great for: shared content, featured items, app configuration, global catalogs

Shared Database (container.sharedCloudDatabase) - Contains records that other users have shared with the current user via CKShare - The current user accesses shared records through this database - Records actually live in the owner's private database — the shared DB is a view into those records - Each share owner gets their own zone within the shared database

let container = CKContainer(identifier: "iCloud.com.yourcompany.cloudjournal")

let privateDB = container.privateCloudDatabase   // User's own data
let publicDB  = container.publicCloudDatabase    // Everyone can read
let sharedDB  = container.sharedCloudDatabase    // Shared by others

CKRecordZone — Namespaces Within a Database

A record zone is a partition within a database. Zones exist to group related records and enable advanced features.

Default Zone: - Exists in every database automatically - Name: _defaultZone - Limited features: no atomic operations, no change tracking, no zone-based subscriptions

Custom Zones (Private DB Only): - You create them with any name you choose - Enable atomic batch operations (all-or-nothing saves) - Enable incremental change tracking via CKServerChangeToken - Enable CKRecordZoneSubscription for push-based sync - Always use a custom zone for your private database data

The public database does not support custom zones. The shared database has zones automatically created per share owner.

// Create a custom zone
let zoneID = CKRecordZone.ID(zoneName: "JournalZone", ownerName: CKCurrentUserDefaultName)
let zone = CKRecordZone(zoneID: zoneID)

CKRecord — The Fundamental Data Unit

A CKRecord is CloudKit's equivalent of a database row. It's a dictionary-like object with typed values and system-managed metadata.

Every CKRecord has: - recordID — a CKRecord.ID containing a unique name + the zone it lives in - recordType — a string identifying the "table" (e.g., "JournalEntry") - User-defined fields — key-value pairs with supported types - System fields — managed by CloudKit: creationDate, modificationDate, creatorUserRecordID, lastModifiedUserRecordID, recordChangeTag

Supported field types: | Swift Type | CloudKit Type | Notes | |---|---|---| | String | String | Max ~1 MB | | Int64 | Int(64) | CloudKit uses 64-bit integers only | | Double | Double | | | Date | Date/Time | | | Data | Bytes | Max ~1 MB per field | | CKAsset | Asset | For large binary data (images, files) | | CKRecord.Reference | Reference | Foreign key to another record | | CLLocation | Location | Latitude/longitude | | [String] | List of Strings | Arrays of any supported type | | [Int64] | List of Int(64) | |

There is no native Boolean type. Use Int64 where 0 = false and 1 = true.

// Create a record
let recordID = CKRecord.ID(recordName: UUID().uuidString, zoneID: zoneID)
let record = CKRecord(recordType: "JournalEntry", recordID: recordID)

// Set fields
record["title"] = "My First Entry" as CKRecordValue
record["body"] = "Today was a great day." as CKRecordValue
record["mood"] = "happy" as CKRecordValue
record["createdAt"] = Date() as CKRecordValue
record["isFavorite"] = Int64(0) as CKRecordValue  // No native Bool

// Read fields
let title = record["title"] as? String
let isFavorite = (record["isFavorite"] as? Int64 ?? 0) == 1

CKRecord.ID — Uniquely Identifying Records

Every record has a CKRecord.ID composed of two parts: - recordName — a unique string within the zone (you provide this, or CloudKit generates one) - zoneID — the CKRecordZone.ID the record lives in

Best practice: Always provide your own recordName using UUID().uuidString. This gives you a deterministic, offline-generatable identifier. If you let CloudKit generate the name, you can only get it after the first save — which breaks offline-first patterns.

// Explicit record name (recommended)
let id = CKRecord.ID(
    recordName: UUID().uuidString,
    zoneID: CKRecordZone.ID(zoneName: "JournalZone", ownerName: CKCurrentUserDefaultName)
)

// Auto-generated name (not recommended for new projects)
let id2 = CKRecord.ID(recordName: CKRecord.ID.init().recordName)

CKServerChangeToken — The Sync Cursor

A CKServerChangeToken is an opaque token that represents "everything that happened up to this point." Think of it as a database cursor for change tracking.

How it works: 1. You fetch changes from a zone, passing nil as the token (first sync — gets everything) 2. CloudKit returns all records plus a new token 3. You store the token locally (UserDefaults, file, etc.) 4. Next time, you pass that token — CloudKit returns only what changed since then 5. Repeat

This is the foundation of efficient sync. Without it, you'd re-fetch your entire dataset every time.

// First sync — pass nil
let token: CKServerChangeToken? = loadSavedToken()

// Fetch changes since token
// CloudKit returns: changed records, deleted record IDs, new token
// Save the new token for next time

3. Project Setup — Xcode, Entitlements & Dashboard

Step 1: Create the Xcode Project

  1. Open Xcode → File → New → Project
  2. Choose App under iOS
  3. Product Name: CloudJournal
  4. Interface: SwiftUI
  5. Language: Swift
  6. Storage: None (we'll add SwiftData manually later)
  7. Click Create

Step 2: Add the CloudKit Capability

  1. Select your project in the navigator
  2. Select the CloudJournal target
  3. Go to the Signing & Capabilities tab
  4. Click + Capability
  5. Search for iCloud and add it
  6. In the iCloud section that appears: - Check CloudKit - Under Containers, click the + button - Enter: iCloud.com.yourcompany.cloudjournal (replace yourcompany with your actual identifier) - Make sure the checkbox next to your container is selected

This automatically: - Adds the iCloud entitlement to your app - Creates the CloudKit container on Apple's servers - Configures your provisioning profile

Step 3: Add Push Notification Capability

CloudKit subscriptions deliver changes via silent push notifications. You need this capability even if your app doesn't show visible notifications.

  1. Still in Signing & Capabilities
  2. Click + CapabilityPush Notifications
  3. Also add Background Modes → check Remote notifications

Step 4: Verify Your Entitlements File

Your CloudJournal.entitlements file should now contain:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>aps-environment</key>
    <string>development</string>
    <key>com.apple.developer.icloud-container-identifiers</key>
    <array>
        <string>iCloud.com.yourcompany.cloudjournal</string>
    </array>
    <key>com.apple.developer.icloud-services</key>
    <array>
        <string>CloudKit</string>
    </array>
</dict>
</plist>

Step 5: Set Up the CloudKit Dashboard

The CloudKit Dashboard is your admin console for managing schemas, viewing data, and monitoring usage.

  1. Open https://icloud.developer.apple.com/dashboard
  2. Sign in with your Apple Developer account
  3. Select your container: iCloud.com.yourcompany.cloudjournal
  4. You'll see two environments: Development and Production
  5. Start in Development — this is where you'll iterate on your schema

Create Record Types:

Navigate to Schema → Record Types → Create New Type:

JournalEntry: | Field Name | Type | Notes | |---|---|---| | title | String | | | body | String | | | mood | String | "happy", "neutral", "sad", "excited", "anxious" | | createdAt | Date/Time | | | modifiedAt | Date/Time | | | isFavorite | Int(64) | 0 or 1 (no native Bool) | | category | Reference | Points to a Category record |

Category: | Field Name | Type | Notes | |---|---|---| | name | String | | | colorHex | String | e.g., "#FF6B6B" | | icon | String | SF Symbol name | | sortOrder | Int(64) | |

EntryImage: | Field Name | Type | Notes | |---|---|---| | entry | Reference | Points to a JournalEntry | | imageAsset | Asset | The actual image file | | caption | String | | | sortOrder | Int(64) | |

Add Indexes:

For each record type, add these indexes under Schema → Record Types → [Type] → Indexes:

JournalEntry:
  recordName    → Queryable (default, always present)
  modifiedAt    → Sortable
  createdAt     → Sortable
  category      → Queryable
  isFavorite    → Queryable
  mood          → Queryable
  title         → Searchable (for full-text search)

Category:
  recordName    → Queryable
  sortOrder     → Sortable
  name          → Queryable

EntryImage:
  recordName    → Queryable
  entry         → Queryable
  sortOrder     → Sortable

Note: You can also let CloudKit create your schema automatically in Development mode. When you save a record with new fields, CloudKit auto-creates the record type and fields. This is convenient for prototyping but you'll want to formalize your schema in the Dashboard before production.

Step 6: Create the App Configuration File

Now let's start coding. Create the foundational configuration that the entire app will use:

// CloudKitConfig.swift
import CloudKit

enum CloudKitConfig {
    // MARK: - Container
    static let containerIdentifier = "iCloud.com.yourcompany.cloudjournal"
    static let container = CKContainer(identifier: containerIdentifier)

    // MARK: - Databases
    static var privateDB: CKDatabase { container.privateCloudDatabase }
    static var publicDB: CKDatabase { container.publicCloudDatabase }
    static var sharedDB: CKDatabase { container.sharedCloudDatabase }

    // MARK: - Custom Zone (Private DB)
    // All private data lives in this zone for atomic operations + change tracking
    static let zoneName = "JournalZone"
    static let zoneID = CKRecordZone.ID(
        zoneName: zoneName,
        ownerName: CKCurrentUserDefaultName
    )

    // MARK: - Record Types
    enum RecordType {
        static let journalEntry = "JournalEntry"
        static let category     = "Category"
        static let entryImage   = "EntryImage"
    }

    // MARK: - Field Keys (avoid stringly-typed access)
    enum EntryField {
        static let title      = "title"
        static let body       = "body"
        static let mood       = "mood"
        static let createdAt  = "createdAt"
        static let modifiedAt = "modifiedAt"
        static let isFavorite = "isFavorite"
        static let category   = "category"
    }

    enum CategoryField {
        static let name      = "name"
        static let colorHex  = "colorHex"
        static let icon      = "icon"
        static let sortOrder = "sortOrder"
    }

    enum ImageField {
        static let entry      = "entry"
        static let imageAsset = "imageAsset"
        static let caption    = "caption"
        static let sortOrder  = "sortOrder"
    }
}

Why an enum and not a struct? Using a caseless enum prevents accidental instantiation. CloudKitConfig() would be meaningless, so we make it impossible.


4. iCloud Account Status & Availability

Before performing any CloudKit operation, you must check whether the user has an iCloud account and whether it's available. This is a hard requirement — CloudKit operations will fail without it.

The Five Account States

public enum CKAccountStatus: Int, @unchecked Sendable {
    case couldNotDetermine = 0  // Network error or system issue
    case available = 1          // Signed in and ready
    case restricted = 2         // Parental controls or MDM restriction
    case noAccount = 3          // Not signed into iCloud
    case temporarilyUnavailable = 4  // Signed in but iCloud not ready yet (iOS 16+)
}

Checking Account Status

// CloudKitManager.swift
import CloudKit
import os

private let logger = Logger(subsystem: "com.yourcompany.cloudjournal", category: "CloudKit")

@Observable
final class CloudKitManager {
    static let shared = CloudKitManager()

    private let container = CloudKitConfig.container
    private(set) var accountStatus: CKAccountStatus = .couldNotDetermine
    private(set) var isAvailable = false

    private init() {}

    /// Call this early — in your App's .task modifier or on first screen appear.
    func checkAccountStatus() async {
        do {
            accountStatus = try await container.accountStatus()
            isAvailable = (accountStatus == .available)

            if isAvailable {
                logger.info("iCloud account available")
            } else {
                logger.warning("iCloud not available: status = \(String(describing: self.accountStatus))")
            }
        } catch {
            logger.error("Failed to check account status: \(error.localizedDescription)")
            accountStatus = .couldNotDetermine
            isAvailable = false
        }
    }

    /// A user-facing message explaining why CloudKit isn't available.
    var unavailableMessage: String? {
        switch accountStatus {
        case .available:
            return nil
        case .noAccount:
            return "Please sign in to iCloud in Settings to sync your journal."
        case .restricted:
            return "iCloud access is restricted on this device."
        case .temporarilyUnavailable:
            return "iCloud is temporarily unavailable. Your data will sync when it's ready."
        case .couldNotDetermine:
            return "Unable to determine iCloud status. Check your internet connection."
        @unknown default:
            return "iCloud is unavailable."
        }
    }
}

Monitoring Account Changes

The user can sign in or out of iCloud at any time. Listen for changes:

extension CloudKitManager {
    /// Start observing iCloud account changes.
    /// Call this once at app launch.
    func startObservingAccountChanges() {
        // CKAccountChanged is posted on the default NotificationCenter
        // whenever the user signs in, signs out, or switches accounts.
        NotificationCenter.default.addObserver(
            forName: .CKAccountChanged,
            object: nil,
            queue: nil
        ) { [weak self] _ in
            Task {
                await self?.checkAccountStatus()
            }
        }
    }
}

SwiftUI Integration — The Account Guard

Create a view modifier that blocks CloudKit-dependent UI when iCloud isn't available:

// Views/CloudKitStatusView.swift
import SwiftUI

struct CloudKitUnavailableView: View {
    let message: String

    var body: some View {
        ContentUnavailableView {
            Label("iCloud Unavailable", systemImage: "icloud.slash")
        } description: {
            Text(message)
        } actions: {
            Button("Open Settings") {
                if let url = URL(string: UIApplication.openSettingsURLString) {
                    UIApplication.shared.open(url)
                }
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

Use it in your root view:

// CloudJournalApp.swift
import SwiftUI

@main
struct CloudJournalApp: App {
    @State private var cloudKit = CloudKitManager.shared

    var body: some Scene {
        WindowGroup {
            Group {
                if cloudKit.isAvailable {
                    JournalListView()
                } else if let message = cloudKit.unavailableMessage {
                    CloudKitUnavailableView(message: message)
                } else {
                    ProgressView("Connecting to iCloud...")
                }
            }
            .task {
                cloudKit.startObservingAccountChanges()
                await cloudKit.checkAccountStatus()
                if cloudKit.isAvailable {
                    await cloudKit.initialSetup()
                }
            }
        }
    }
}

Part 2 — Core Operations (Building CRUD)


5. Records, Zones, and Identifiers

Before writing CRUD operations, let's build our domain model layer. The critical design decision: never let CKRecord leak beyond the boundary layer. Convert to Swift value types immediately.

Domain Models

// Models/JournalEntry.swift
import Foundation
import CloudKit

/// Represents a mood for a journal entry.
/// Stored as a raw String in CloudKit (no native enum support).
enum Mood: String, CaseIterable, Identifiable, Codable {
    case happy, neutral, sad, excited, anxious

    var id: String { rawValue }

    var displayName: String {
        rawValue.capitalized
    }

    var icon: String {
        switch self {
        case .happy:   return "face.smiling"
        case .neutral: return "face.dashed"
        case .sad:     return "cloud.rain"
        case .excited: return "star.fill"
        case .anxious: return "bolt.heart"
        }
    }
}

struct JournalEntry: Identifiable, Hashable, Sendable {
    let id: CKRecord.ID
    var title: String
    var body: String
    var mood: Mood
    var createdAt: Date
    var modifiedAt: Date
    var isFavorite: Bool
    var categoryID: CKRecord.ID?

    /// The backing CKRecord. We retain this so that when we save changes,
    /// the system fields (changeTag, etc.) are preserved. This prevents
    /// unnecessary conflicts with the server.
    let record: CKRecord

    // Hashable conformance uses only the ID — two entries with the same
    // CKRecord.ID are the same entry regardless of field values.
    static func == (lhs: JournalEntry, rhs: JournalEntry) -> Bool {
        lhs.id == rhs.id
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

Why keep record? When you modify a CKRecord and save it, CloudKit uses the record's internal recordChangeTag to detect conflicts. If you create a fresh CKRecord with the same ID instead of modifying the original, CloudKit treats it as a new record and may fail with .serverRecordChanged. Keeping the original record preserves these system fields.

CKRecord ↔ Model Conversion

This is the boundary layer — the only place in your codebase that touches CKRecord fields directly.

// Extensions/JournalEntry+CloudKit.swift
import CloudKit

extension JournalEntry {

    /// Initialize from a CKRecord fetched from CloudKit.
    /// Returns nil if required fields are missing (defensive parsing).
    init?(record: CKRecord) {
        guard record.recordType == CloudKitConfig.RecordType.journalEntry else {
            return nil
        }

        // Required fields — fail if missing
        guard let title = record[CloudKitConfig.EntryField.title] as? String,
              let body = record[CloudKitConfig.EntryField.body] as? String,
              let createdAt = record[CloudKitConfig.EntryField.createdAt] as? Date,
              let modifiedAt = record[CloudKitConfig.EntryField.modifiedAt] as? Date
        else {
            return nil
        }

        self.id = record.recordID
        self.title = title
        self.body = body
        self.createdAt = createdAt
        self.modifiedAt = modifiedAt
        self.record = record

        // Optional fields — use defaults
        let moodString = record[CloudKitConfig.EntryField.mood] as? String ?? "neutral"
        self.mood = Mood(rawValue: moodString) ?? .neutral

        self.isFavorite = (record[CloudKitConfig.EntryField.isFavorite] as? Int64 ?? 0) == 1

        self.categoryID = (record[CloudKitConfig.EntryField.category] as? CKRecord.Reference)?.recordID
    }

    /// Write this entry's fields onto its backing CKRecord.
    /// Call this before saving — it only sets the fields we own,
    /// leaving system fields intact.
    func toRecord() -> CKRecord {
        let r = record
        r[CloudKitConfig.EntryField.title]      = title as CKRecordValue
        r[CloudKitConfig.EntryField.body]       = body as CKRecordValue
        r[CloudKitConfig.EntryField.mood]       = mood.rawValue as CKRecordValue
        r[CloudKitConfig.EntryField.createdAt]  = createdAt as CKRecordValue
        r[CloudKitConfig.EntryField.modifiedAt] = modifiedAt as CKRecordValue
        r[CloudKitConfig.EntryField.isFavorite] = Int64(isFavorite ? 1 : 0) as CKRecordValue

        if let categoryID {
            r[CloudKitConfig.EntryField.category] = CKRecord.Reference(
                recordID: categoryID,
                action: .none  // Don't cascade-delete entries when category is deleted
            )
        } else {
            r[CloudKitConfig.EntryField.category] = nil
        }

        return r
    }

    /// Creates a brand-new CKRecord in the custom zone.
    /// Use this when creating a new entry from scratch.
    static func newRecord() -> CKRecord {
        let recordID = CKRecord.ID(
            recordName: UUID().uuidString,
            zoneID: CloudKitConfig.zoneID
        )
        return CKRecord(
            recordType: CloudKitConfig.RecordType.journalEntry,
            recordID: recordID
        )
    }
}

Now do the same for Category:

// Models/Category.swift
import Foundation
import CloudKit

struct JournalCategory: Identifiable, Hashable, Sendable {
    let id: CKRecord.ID
    var name: String
    var colorHex: String
    var icon: String
    var sortOrder: Int
    let record: CKRecord

    static func == (lhs: JournalCategory, rhs: JournalCategory) -> Bool {
        lhs.id == rhs.id
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

// Extensions/Category+CloudKit.swift
extension JournalCategory {
    init?(record: CKRecord) {
        guard record.recordType == CloudKitConfig.RecordType.category,
              let name = record[CloudKitConfig.CategoryField.name] as? String
        else { return nil }

        self.id = record.recordID
        self.name = name
        self.colorHex = record[CloudKitConfig.CategoryField.colorHex] as? String ?? "#007AFF"
        self.icon = record[CloudKitConfig.CategoryField.icon] as? String ?? "folder"
        self.sortOrder = Int(record[CloudKitConfig.CategoryField.sortOrder] as? Int64 ?? 0)
        self.record = record
    }

    func toRecord() -> CKRecord {
        let r = record
        r[CloudKitConfig.CategoryField.name] = name as CKRecordValue
        r[CloudKitConfig.CategoryField.colorHex] = colorHex as CKRecordValue
        r[CloudKitConfig.CategoryField.icon] = icon as CKRecordValue
        r[CloudKitConfig.CategoryField.sortOrder] = Int64(sortOrder) as CKRecordValue
        return r
    }

    static func newRecord() -> CKRecord {
        let recordID = CKRecord.ID(
            recordName: UUID().uuidString,
            zoneID: CloudKitConfig.zoneID
        )
        return CKRecord(
            recordType: CloudKitConfig.RecordType.category,
            recordID: recordID
        )
    }
}

Zone Setup

The custom zone must exist before you save any records to it. Zone creation is idempotent — creating a zone that already exists is a no-op, not an error.

// In CloudKitManager.swift
extension CloudKitManager {
    /// Create the custom zone. Safe to call on every launch.
    func ensureZoneExists() async throws {
        let zone = CKRecordZone(zoneID: CloudKitConfig.zoneID)
        let result = try await CloudKitConfig.privateDB.modifyRecordZones(
            saving: [zone],
            deleting: []
        )

        for (zoneID, saveResult) in result.saveResults {
            switch saveResult {
            case .success:
                logger.info("Zone ready: \(zoneID.zoneName)")
            case .failure(let error):
                // Ignore "zone already exists" — that's fine
                if let ckError = error as? CKError, ckError.code == .serverRejectedRequest {
                    logger.info("Zone already exists: \(zoneID.zoneName)")
                } else {
                    throw error
                }
            }
        }
    }

    /// Call once at app launch after confirming account is available.
    func initialSetup() async {
        do {
            try await ensureZoneExists()
            logger.info("Initial setup complete")
        } catch {
            logger.error("Initial setup failed: \(error.localizedDescription)")
        }
    }
}

6. Creating and Saving Records

Now we have our models and zone. Let's create journal entries.

The Convenience API vs. The Operation API

CloudKit provides two ways to interact with records:

Convenience API — Simple async/await methods directly on CKDatabase. Good for single-record operations and prototyping.

// Save one record
let savedRecord = try await database.save(record)

// Fetch one record
let record = try await database.record(for: recordID)

Operation API — Batch operations via CKModifyRecordsOperation, CKFetchRecordsOperation, etc. Required for production because: - You can save/delete multiple records in one network round-trip - You get per-record progress callbacks - You can set QoS (Quality of Service) priorities - You can configure timeouts and retry behavior

In iOS 15+, CKDatabase also has async batch methods that combine the simplicity of the convenience API with the power of the operation API. These are what we'll primarily use:

// Batch save/delete with async/await (iOS 15+)
let result = try await database.modifyRecords(
    saving: [record1, record2],
    deleting: [recordID3],
    savePolicy: .changedKeys,
    atomically: true
)

Creating a Journal Entry

// In CloudKitManager.swift
extension CloudKitManager {

    // MARK: - Create Entry

    /// Creates a new journal entry and saves it to CloudKit.
    @discardableResult
    func createEntry(
        title: String,
        body: String,
        mood: Mood = .neutral,
        categoryID: CKRecord.ID? = nil
    ) async throws -> JournalEntry {
        let record = JournalEntry.newRecord()

        let now = Date()
        record[CloudKitConfig.EntryField.title]      = title as CKRecordValue
        record[CloudKitConfig.EntryField.body]       = body as CKRecordValue
        record[CloudKitConfig.EntryField.mood]       = mood.rawValue as CKRecordValue
        record[CloudKitConfig.EntryField.createdAt]  = now as CKRecordValue
        record[CloudKitConfig.EntryField.modifiedAt] = now as CKRecordValue
        record[CloudKitConfig.EntryField.isFavorite] = Int64(0) as CKRecordValue

        if let categoryID {
            record[CloudKitConfig.EntryField.category] = CKRecord.Reference(
                recordID: categoryID,
                action: .none
            )
        }

        let savedRecord = try await CloudKitConfig.privateDB.save(record)

        guard let entry = JournalEntry(record: savedRecord) else {
            throw CloudKitAppError.invalidRecord
        }

        logger.info("Created entry: \(entry.title)")
        return entry
    }
}

// Errors/CloudKitAppError.swift
enum CloudKitAppError: LocalizedError {
    case invalidRecord
    case notAuthenticated
    case quotaExceeded
    case networkUnavailable

    var errorDescription: String? {
        switch self {
        case .invalidRecord:     return "The record data was invalid."
        case .notAuthenticated:  return "Please sign in to iCloud."
        case .quotaExceeded:     return "iCloud storage is full."
        case .networkUnavailable: return "No internet connection."
        }
    }
}

Creating a Category

extension CloudKitManager {

    @discardableResult
    func createCategory(
        name: String,
        colorHex: String = "#007AFF",
        icon: String = "folder",
        sortOrder: Int = 0
    ) async throws -> JournalCategory {
        let record = JournalCategory.newRecord()

        record[CloudKitConfig.CategoryField.name]      = name as CKRecordValue
        record[CloudKitConfig.CategoryField.colorHex]  = colorHex as CKRecordValue
        record[CloudKitConfig.CategoryField.icon]      = icon as CKRecordValue
        record[CloudKitConfig.CategoryField.sortOrder] = Int64(sortOrder) as CKRecordValue

        let savedRecord = try await CloudKitConfig.privateDB.save(record)

        guard let category = JournalCategory(record: savedRecord) else {
            throw CloudKitAppError.invalidRecord
        }

        logger.info("Created category: \(category.name)")
        return category
    }
}

7. Fetching and Querying Records

CloudKit queries use CKQuery with NSPredicate for filtering and NSSortDescriptor for ordering. The predicate language is a subset of the full NSPredicate — not all operators are supported.

Supported Predicate Operators

Operator Example Notes
== "mood == %@" Equality
!= "mood != %@" Inequality
>, >=, <, <= "createdAt > %@" Comparison
IN "mood IN %@" Value in array
CONTAINS "title CONTAINS %@" Field must be marked Searchable
BEGINSWITH "title BEGINSWITH %@"
AND, OR, NOT "isFavorite == 1 AND mood == %@" Compound
TRUEPREDICATE NSPredicate(value: true) Fetch all

NOT supported: Regular expressions, LIKE, MATCHES, subqueries, aggregate functions, BETWEEN.

Fetch All Entries

extension CloudKitManager {

    // MARK: - Fetch Entries

    /// Fetches all journal entries, handling pagination automatically.
    func fetchAllEntries() async throws -> [JournalEntry] {
        let query = CKQuery(
            recordType: CloudKitConfig.RecordType.journalEntry,
            predicate: NSPredicate(value: true)  // Fetch all
        )
        query.sortDescriptors = [
            NSSortDescriptor(key: CloudKitConfig.EntryField.modifiedAt, ascending: false)
        ]

        var entries: [JournalEntry] = []
        var cursor: CKQueryOperation.Cursor? = nil

        // CloudKit returns results in pages. The cursor points to the next page.
        // Keep fetching until cursor is nil (no more pages).
        repeat {
            let (results, nextCursor) = try await CloudKitConfig.privateDB.records(
                matching: query,
                inZoneWith: CloudKitConfig.zoneID,  // Our custom zone
                desiredKeys: nil,                    // nil = all fields
                resultsLimit: 200,                   // Max per page (CloudKit max is 400)
                after: cursor
            )

            for (_, result) in results {
                if case .success(let record) = result,
                   let entry = JournalEntry(record: record) {
                    entries.append(entry)
                }
            }

            cursor = nextCursor
        } while cursor != nil

        logger.info("Fetched \(entries.count) entries")
        return entries
    }

    /// Fetches a single entry by its record ID.
    func fetchEntry(id: CKRecord.ID) async throws -> JournalEntry? {
        let record = try await CloudKitConfig.privateDB.record(for: id)
        return JournalEntry(record: record)
    }
}

Query with Filters

extension CloudKitManager {

    /// Fetch entries filtered by mood.
    func fetchEntries(withMood mood: Mood) async throws -> [JournalEntry] {
        let predicate = NSPredicate(
            format: "%K == %@",
            CloudKitConfig.EntryField.mood,
            mood.rawValue
        )
        let query = CKQuery(
            recordType: CloudKitConfig.RecordType.journalEntry,
            predicate: predicate
        )
        query.sortDescriptors = [
            NSSortDescriptor(key: CloudKitConfig.EntryField.modifiedAt, ascending: false)
        ]

        let (results, _) = try await CloudKitConfig.privateDB.records(
            matching: query,
            inZoneWith: CloudKitConfig.zoneID
        )

        return results.compactMap { _, result in
            guard case .success(let record) = result else { return nil }
            return JournalEntry(record: record)
        }
    }

    /// Fetch only favorite entries.
    func fetchFavoriteEntries() async throws -> [JournalEntry] {
        let predicate = NSPredicate(
            format: "%K == %d",
            CloudKitConfig.EntryField.isFavorite,
            Int64(1)
        )
        let query = CKQuery(
            recordType: CloudKitConfig.RecordType.journalEntry,
            predicate: predicate
        )
        query.sortDescriptors = [
            NSSortDescriptor(key: CloudKitConfig.EntryField.modifiedAt, ascending: false)
        ]

        let (results, _) = try await CloudKitConfig.privateDB.records(
            matching: query,
            inZoneWith: CloudKitConfig.zoneID
        )

        return results.compactMap { _, result in
            guard case .success(let record) = result else { return nil }
            return JournalEntry(record: record)
        }
    }

    /// Fetch entries in a specific category.
    func fetchEntries(in category: JournalCategory) async throws -> [JournalEntry] {
        let categoryRef = CKRecord.Reference(recordID: category.id, action: .none)
        let predicate = NSPredicate(
            format: "%K == %@",
            CloudKitConfig.EntryField.category,
            categoryRef
        )
        let query = CKQuery(
            recordType: CloudKitConfig.RecordType.journalEntry,
            predicate: predicate
        )
        query.sortDescriptors = [
            NSSortDescriptor(key: CloudKitConfig.EntryField.modifiedAt, ascending: false)
        ]

        let (results, _) = try await CloudKitConfig.privateDB.records(
            matching: query,
            inZoneWith: CloudKitConfig.zoneID
        )

        return results.compactMap { _, result in
            guard case .success(let record) = result else { return nil }
            return JournalEntry(record: record)
        }
    }

    /// Full-text search across titles.
    /// Requires "title" to be marked as Searchable in CloudKit Dashboard indexes.
    func searchEntries(text: String) async throws -> [JournalEntry] {
        // CONTAINS requires the field to have a SEARCHABLE index
        let predicate = NSPredicate(
            format: "self contains %@",
            text
        )
        let query = CKQuery(
            recordType: CloudKitConfig.RecordType.journalEntry,
            predicate: predicate
        )

        let (results, _) = try await CloudKitConfig.privateDB.records(
            matching: query,
            inZoneWith: CloudKitConfig.zoneID
        )

        return results.compactMap { _, result in
            guard case .success(let record) = result else { return nil }
            return JournalEntry(record: record)
        }
    }

    /// Compound query: favorites created this month with a specific mood.
    func fetchRecentFavorites(mood: Mood, since date: Date) async throws -> [JournalEntry] {
        let predicate = NSPredicate(
            format: "%K == %d AND %K == %@ AND %K > %@",
            CloudKitConfig.EntryField.isFavorite, Int64(1),
            CloudKitConfig.EntryField.mood, mood.rawValue,
            CloudKitConfig.EntryField.createdAt, date as NSDate
        )
        let query = CKQuery(
            recordType: CloudKitConfig.RecordType.journalEntry,
            predicate: predicate
        )

        let (results, _) = try await CloudKitConfig.privateDB.records(
            matching: query,
            inZoneWith: CloudKitConfig.zoneID
        )

        return results.compactMap { _, result in
            guard case .success(let record) = result else { return nil }
            return JournalEntry(record: record)
        }
    }

    /// Fetch all categories.
    func fetchAllCategories() async throws -> [JournalCategory] {
        let query = CKQuery(
            recordType: CloudKitConfig.RecordType.category,
            predicate: NSPredicate(value: true)
        )
        query.sortDescriptors = [
            NSSortDescriptor(key: CloudKitConfig.CategoryField.sortOrder, ascending: true)
        ]

        let (results, _) = try await CloudKitConfig.privateDB.records(
            matching: query,
            inZoneWith: CloudKitConfig.zoneID
        )

        return results.compactMap { _, result in
            guard case .success(let record) = result else { return nil }
            return JournalCategory(record: record)
        }
    }
}

Fetching Specific Fields Only (Partial Records)

When you only need a few fields (e.g., for a list view), use desiredKeys to avoid downloading unnecessary data. This is especially important when records have CKAsset fields — you don't want to download every image just to show a list.

extension CloudKitManager {
    /// Fetch entries with only the fields needed for a list view.
    func fetchEntryPreviews() async throws -> [JournalEntry] {
        let query = CKQuery(
            recordType: CloudKitConfig.RecordType.journalEntry,
            predicate: NSPredicate(value: true)
        )
        query.sortDescriptors = [
            NSSortDescriptor(key: CloudKitConfig.EntryField.modifiedAt, ascending: false)
        ]

        // Only fetch these fields — skip "body" for list performance
        let previewKeys: [CKRecord.FieldKey] = [
            CloudKitConfig.EntryField.title,
            CloudKitConfig.EntryField.mood,
            CloudKitConfig.EntryField.modifiedAt,
            CloudKitConfig.EntryField.isFavorite
        ]

        let (results, _) = try await CloudKitConfig.privateDB.records(
            matching: query,
            inZoneWith: CloudKitConfig.zoneID,
            desiredKeys: previewKeys,
            resultsLimit: 50
        )

        return results.compactMap { _, result in
            guard case .success(let record) = result else { return nil }
            // Note: "body" will be nil on these records since we didn't request it
            return JournalEntry(record: record)
        }
    }
}

8. Updating Records & Save Policies

Modifying an Existing Entry

To update a record, modify the backing CKRecord and save it. This is why we kept the record property on our model — it preserves the system fields that CloudKit needs for conflict detection.

extension CloudKitManager {

    // MARK: - Update Entry

    @discardableResult
    func updateEntry(_ entry: JournalEntry) async throws -> JournalEntry {
        var modified = entry
        modified.modifiedAt = Date()

        let record = modified.toRecord()
        let savedRecord = try await CloudKitConfig.privateDB.save(record)

        guard let updated = JournalEntry(record: savedRecord) else {
            throw CloudKitAppError.invalidRecord
        }

        logger.info("Updated entry: \(updated.title)")
        return updated
    }

    /// Toggle an entry's favorite status.
    func toggleFavorite(_ entry: JournalEntry) async throws -> JournalEntry {
        var modified = entry
        modified.isFavorite.toggle()
        return try await updateEntry(modified)
    }

    /// Move an entry to a different category.
    func moveEntry(_ entry: JournalEntry, to category: JournalCategory?) async throws -> JournalEntry {
        var modified = entry
        modified.categoryID = category?.id
        return try await updateEntry(modified)
    }
}

Save Policies Explained

When saving records via modifyRecords, the savePolicy parameter controls how CloudKit handles field conflicts:

// The three save policies:

// 1. .ifServerRecordUnchanged (default)
// Only saves if the server record hasn't changed since you fetched it.
// Uses the recordChangeTag to detect this. Safe but strict — fails if
// ANY other device saved the record since you fetched it.
// Result: CKError.serverRecordChanged if there's a conflict.

// 2. .changedKeys
// Only sends the fields you explicitly set on the CKRecord to the server.
// Other fields on the server are preserved, even if changed by another device.
// BEST FOR MOST CASES — allows independent field updates from multiple devices.

// 3. .allKeys
// Overwrites EVERY field on the server, blanking anything you didn't set.
// Dangerous for partial updates — use only for "force overwrite" scenarios.

Example showing the difference:

// Device A fetches a record:
//   title = "Morning Run", mood = "happy", body = "Ran 5 miles"

// Device B changes mood to "excited" and saves

// Device A changes title to "Morning Jog" and tries to save:

// With .ifServerRecordUnchanged:
//   FAILS with .serverRecordChanged because the record changed (Device B's edit)

// With .changedKeys:
//   SUCCEEDS — only sends title="Morning Jog" to server
//   Server now has: title="Morning Jog", mood="excited", body="Ran 5 miles"

// With .allKeys:
//   SUCCEEDS — sends title="Morning Jog", mood="happy", body="Ran 5 miles"
//   Server now has: title="Morning Jog", mood="happy" (Device B's change LOST!)

When using the batch API:

let result = try await CloudKitConfig.privateDB.modifyRecords(
    saving: [record1, record2],
    deleting: [],
    savePolicy: .changedKeys,  // <-- Set here
    atomically: true
)

9. Deleting Records

Single Delete

extension CloudKitManager {

    // MARK: - Delete Entry

    func deleteEntry(_ entry: JournalEntry) async throws {
        try await CloudKitConfig.privateDB.deleteRecord(withID: entry.id)
        logger.info("Deleted entry: \(entry.title)")
    }

    func deleteCategory(_ category: JournalCategory) async throws {
        try await CloudKitConfig.privateDB.deleteRecord(withID: category.id)
        logger.info("Deleted category: \(category.name)")
    }
}

Batch Delete

extension CloudKitManager {

    /// Delete multiple entries atomically.
    func deleteEntries(_ entries: [JournalEntry]) async throws {
        let ids = entries.map(\.id)

        let result = try await CloudKitConfig.privateDB.modifyRecords(
            saving: [],
            deleting: ids,
            savePolicy: .changedKeys,
            atomically: true  // All or nothing
        )

        // Check for per-record failures
        for (recordID, deleteResult) in result.deleteResults {
            if case .failure(let error) = deleteResult {
                logger.error("Failed to delete \(recordID.recordName): \(error.localizedDescription)")
                throw error
            }
        }

        logger.info("Deleted \(ids.count) entries")
    }
}

Delete Gotchas

Cascade deletes: If you created EntryImage records with a reference to JournalEntry using action .deleteSelf, deleting the entry automatically deletes all its images. If you used .none, the images become orphaned — you must delete them manually.

Soft deletes vs. hard deletes: CloudKit delete is permanent. There's no "trash" or undo. If you want a soft delete, add an isDeleted field and filter it in your queries. You can then permanently purge soft-deleted records after a retention period.


10. Batch Operations & Atomicity

Why Batching Matters

Every CloudKit API call is a network round-trip. If you save 50 records one at a time, that's 50 HTTP requests. Batching them into a single modifyRecords call sends them in one request — dramatically faster and more reliable.

Atomic vs. Non-Atomic

Atomic (atomically: true): - All records save successfully, or none do - If one record fails (e.g., conflict), the entire batch is rolled back - Only works in custom zones (not the default zone, not the public DB) - Use for: related records that must stay consistent (entry + its images)

Non-Atomic (atomically: false): - Each record is saved independently - Some can succeed while others fail - Check result.saveResults for per-record outcomes - Use for: independent records where partial success is acceptable

extension CloudKitManager {

    // MARK: - Batch Save (Atomic)

    /// Saves multiple records atomically. Either ALL succeed or NONE do.
    func batchSave(records: [CKRecord]) async throws -> [CKRecord] {
        let result = try await CloudKitConfig.privateDB.modifyRecords(
            saving: records,
            deleting: [],
            savePolicy: .changedKeys,
            atomically: true
        )

        var saved: [CKRecord] = []
        for (_, saveResult) in result.saveResults {
            switch saveResult {
            case .success(let record):
                saved.append(record)
            case .failure(let error):
                // In atomic mode, if one fails, all fail.
                // Throw on the first failure.
                throw error
            }
        }

        logger.info("Batch saved \(saved.count) records atomically")
        return saved
    }

    // MARK: - Batch Save (Non-Atomic, Partial Success)

    struct BatchResult {
        var saved: [CKRecord] = []
        var failed: [(CKRecord.ID, Error)] = []
    }

    /// Saves multiple records non-atomically. Returns both successes and failures.
    func batchSavePartial(records: [CKRecord]) async throws -> BatchResult {
        let result = try await CloudKitConfig.privateDB.modifyRecords(
            saving: records,
            deleting: [],
            savePolicy: .changedKeys,
            atomically: false
        )

        var batchResult = BatchResult()

        for (recordID, saveResult) in result.saveResults {
            switch saveResult {
            case .success(let record):
                batchResult.saved.append(record)
            case .failure(let error):
                batchResult.failed.append((recordID, error))
            }
        }

        if !batchResult.failed.isEmpty {
            logger.warning("Batch save: \(batchResult.saved.count) succeeded, \(batchResult.failed.count) failed")
        }

        return batchResult
    }

    // MARK: - Batch Fetch by IDs

    /// Fetch multiple records by their IDs in a single request.
    /// CloudKit allows up to 400 records per request.
    func fetchRecords(ids: [CKRecord.ID]) async throws -> [CKRecord] {
        var allRecords: [CKRecord] = []

        // Chunk into batches of 400 (CloudKit's per-operation limit)
        for batch in ids.chunked(into: 400) {
            let results = try await CloudKitConfig.privateDB.records(for: batch)

            for (_, result) in results {
                if case .success(let record) = result {
                    allRecords.append(record)
                }
            }
        }

        return allRecords
    }
}

// Helper
extension Array {
    func chunked(into size: Int) -> [[Element]] {
        stride(from: 0, to: count, by: size).map {
            Array(self[$0..<Swift.min($0 + size, count)])
        }
    }
}

Practical Example: Create Entry with Category

Creating an entry and assigning it to a new category should be atomic — you don't want an entry pointing to a category that failed to save:

extension CloudKitManager {
    /// Creates a new category and an entry within it, atomically.
    func createEntryWithNewCategory(
        entryTitle: String,
        entryBody: String,
        categoryName: String,
        categoryColor: String
    ) async throws -> (JournalEntry, JournalCategory) {
        // Prepare both records
        let categoryRecord = JournalCategory.newRecord()
        categoryRecord[CloudKitConfig.CategoryField.name] = categoryName as CKRecordValue
        categoryRecord[CloudKitConfig.CategoryField.colorHex] = categoryColor as CKRecordValue
        categoryRecord[CloudKitConfig.CategoryField.icon] = "folder" as CKRecordValue
        categoryRecord[CloudKitConfig.CategoryField.sortOrder] = Int64(0) as CKRecordValue

        let entryRecord = JournalEntry.newRecord()
        let now = Date()
        entryRecord[CloudKitConfig.EntryField.title] = entryTitle as CKRecordValue
        entryRecord[CloudKitConfig.EntryField.body] = entryBody as CKRecordValue
        entryRecord[CloudKitConfig.EntryField.mood] = Mood.neutral.rawValue as CKRecordValue
        entryRecord[CloudKitConfig.EntryField.createdAt] = now as CKRecordValue
        entryRecord[CloudKitConfig.EntryField.modifiedAt] = now as CKRecordValue
        entryRecord[CloudKitConfig.EntryField.isFavorite] = Int64(0) as CKRecordValue

        // Link entry to category
        entryRecord[CloudKitConfig.EntryField.category] = CKRecord.Reference(
            recordID: categoryRecord.recordID,
            action: .none
        )

        // Save atomically — both succeed or both fail
        let saved = try await batchSave(records: [categoryRecord, entryRecord])

        guard let savedCategory = saved.first(where: { $0.recordType == CloudKitConfig.RecordType.category }),
              let savedEntry = saved.first(where: { $0.recordType == CloudKitConfig.RecordType.journalEntry }),
              let category = JournalCategory(record: savedCategory),
              let entry = JournalEntry(record: savedEntry)
        else {
            throw CloudKitAppError.invalidRecord
        }

        return (entry, category)
    }
}

Part 3 — Intermediate Topics


11. Custom Record Zones & Why They Matter

We created a custom zone in Section 5 but haven't explored why it's so important. Here's the full picture.

Default Zone vs. Custom Zone

Feature Default Zone Custom Zone
Exists automatically Yes Must be created
Atomic batch saves No Yes
Change tracking (CKServerChangeToken) No Yes
CKRecordZoneSubscription No Yes
CKSyncEngine support No Yes
Sharing (CKShare) No Yes
Available in Public DB Yes (only option) No

The default zone is essentially a legacy zone with minimal features. For any real app, always create a custom zone for private database data.

Multiple Zones

You can have multiple custom zones within the private database. Use cases:

For our CloudJournal app, a single zone is sufficient. Multiple zones add complexity (more tokens to track, more sync operations) without benefit unless you have genuinely independent data sets.

Zone Operations

extension CloudKitManager {

    /// List all custom zones in the private database.
    func listAllZones() async throws -> [CKRecordZone] {
        try await CloudKitConfig.privateDB.allRecordZones()
    }

    /// Delete a custom zone and ALL records within it.
    /// This is irreversible and cascading.
    func deleteZone(zoneID: CKRecordZone.ID) async throws {
        let result = try await CloudKitConfig.privateDB.modifyRecordZones(
            saving: [],
            deleting: [zoneID]
        )

        for (deletedID, deleteResult) in result.deleteResults {
            if case .failure(let error) = deleteResult {
                logger.error("Failed to delete zone \(deletedID.zoneName): \(error)")
                throw error
            }
        }

        logger.info("Deleted zone: \(zoneID.zoneName)")
    }
}

12. Binary Data with CKAsset

When to Use CKAsset vs. Data Fields

CKAsset requires a file URL — you can't create one from Data directly. You must write to a temp file first.

The EntryImage Model

// Models/EntryImage.swift
import Foundation
import CloudKit

struct EntryImage: Identifiable, Hashable, Sendable {
    let id: CKRecord.ID
    var entryID: CKRecord.ID
    var caption: String
    var sortOrder: Int
    var localFileURL: URL?  // Available after downloading the asset
    let record: CKRecord

    static func == (lhs: EntryImage, rhs: EntryImage) -> Bool { lhs.id == rhs.id }
    func hash(into hasher: inout Hasher) { hasher.combine(id) }
}

extension EntryImage {
    init?(record: CKRecord) {
        guard record.recordType == CloudKitConfig.RecordType.entryImage,
              let entryRef = record[CloudKitConfig.ImageField.entry] as? CKRecord.Reference
        else { return nil }

        self.id = record.recordID
        self.entryID = entryRef.recordID
        self.caption = record[CloudKitConfig.ImageField.caption] as? String ?? ""
        self.sortOrder = Int(record[CloudKitConfig.ImageField.sortOrder] as? Int64 ?? 0)
        self.record = record

        // Extract the local file URL if the asset was fetched
        if let asset = record[CloudKitConfig.ImageField.imageAsset] as? CKAsset {
            self.localFileURL = asset.fileURL
        }
    }
}

Upload an Image

extension CloudKitManager {

    // MARK: - Image Upload

    /// Uploads an image as a CKAsset attached to a journal entry.
    func uploadImage(
        _ image: UIImage,
        to entry: JournalEntry,
        caption: String = "",
        sortOrder: Int = 0
    ) async throws -> EntryImage {
        // 1. Compress and write to temp file (CKAsset requires a file URL)
        guard let imageData = image.jpegData(compressionQuality: 0.85) else {
            throw CloudKitAppError.invalidRecord
        }

        let tempURL = FileManager.default.temporaryDirectory
            .appendingPathComponent(UUID().uuidString, conformingTo: .jpeg)
        try imageData.write(to: tempURL)

        // Always clean up the temp file, even if upload fails
        defer { try? FileManager.default.removeItem(at: tempURL) }

        // 2. Create the record
        let recordID = CKRecord.ID(
            recordName: UUID().uuidString,
            zoneID: CloudKitConfig.zoneID
        )
        let record = CKRecord(
            recordType: CloudKitConfig.RecordType.entryImage,
            recordID: recordID
        )

        // 3. Set the CKAsset
        record[CloudKitConfig.ImageField.imageAsset] = CKAsset(fileURL: tempURL)
        record[CloudKitConfig.ImageField.caption] = caption as CKRecordValue
        record[CloudKitConfig.ImageField.sortOrder] = Int64(sortOrder) as CKRecordValue

        // 4. Link to the parent entry with cascade delete
        record[CloudKitConfig.ImageField.entry] = CKRecord.Reference(
            recordID: entry.id,
            action: .deleteSelf  // When entry is deleted, images are auto-deleted
        )

        // 5. Save
        let savedRecord = try await CloudKitConfig.privateDB.save(record)

        guard let entryImage = EntryImage(record: savedRecord) else {
            throw CloudKitAppError.invalidRecord
        }

        logger.info("Uploaded image for entry: \(entry.title)")
        return entryImage
    }
}

Download an Image with Caching

extension CloudKitManager {

    // MARK: - Image Download

    /// Downloads an image asset, caching it locally.
    /// Returns the local file URL of the cached image.
    func downloadImage(_ entryImage: EntryImage) async throws -> URL {
        // Check cache first
        let cacheURL = imageCacheURL(for: entryImage.id)
        if FileManager.default.fileExists(atPath: cacheURL.path()) {
            return cacheURL
        }

        // Fetch the full record (including the asset)
        let record = try await CloudKitConfig.privateDB.record(for: entryImage.id)

        guard let asset = record[CloudKitConfig.ImageField.imageAsset] as? CKAsset,
              let assetURL = asset.fileURL
        else {
            throw CloudKitAppError.invalidRecord
        }

        // Copy from CloudKit's temp location to our cache directory
        let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("journal-images", isDirectory: true)
        try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)

        // If cached file already exists (race condition), remove it first
        if FileManager.default.fileExists(atPath: cacheURL.path()) {
            try FileManager.default.removeItem(at: cacheURL)
        }

        try FileManager.default.copyItem(at: assetURL, to: cacheURL)
        return cacheURL
    }

    /// Fetches image metadata for an entry WITHOUT downloading the asset data.
    func fetchImageMetadata(for entry: JournalEntry) async throws -> [EntryImage] {
        let entryRef = CKRecord.Reference(recordID: entry.id, action: .none)
        let predicate = NSPredicate(format: "%K == %@", CloudKitConfig.ImageField.entry, entryRef)
        let query = CKQuery(
            recordType: CloudKitConfig.RecordType.entryImage,
            predicate: predicate
        )
        query.sortDescriptors = [
            NSSortDescriptor(key: CloudKitConfig.ImageField.sortOrder, ascending: true)
        ]

        // Fetch ONLY metadata — exclude the heavy imageAsset field
        let metadataKeys: [CKRecord.FieldKey] = [
            CloudKitConfig.ImageField.entry,
            CloudKitConfig.ImageField.caption,
            CloudKitConfig.ImageField.sortOrder
        ]

        let (results, _) = try await CloudKitConfig.privateDB.records(
            matching: query,
            inZoneWith: CloudKitConfig.zoneID,
            desiredKeys: metadataKeys
        )

        return results.compactMap { _, result in
            guard case .success(let record) = result else { return nil }
            return EntryImage(record: record)
        }
    }

    // MARK: - Delete Image

    func deleteImage(_ image: EntryImage) async throws {
        try await CloudKitConfig.privateDB.deleteRecord(withID: image.id)
        // Clean up cache
        let cacheURL = imageCacheURL(for: image.id)
        try? FileManager.default.removeItem(at: cacheURL)
    }

    // MARK: - Helpers

    private func imageCacheURL(for recordID: CKRecord.ID) -> URL {
        FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("journal-images", isDirectory: true)
            .appendingPathComponent(recordID.recordName)
    }
}

SwiftUI Lazy Image Loader

// Views/AsyncCKImage.swift
import SwiftUI

struct AsyncCKImage: View {
    let entryImage: EntryImage
    @State private var uiImage: UIImage?
    @State private var isLoading = false
    @State private var loadFailed = false

    var body: some View {
        Group {
            if let uiImage {
                Image(uiImage: uiImage)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            } else if isLoading {
                ProgressView()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color.secondary.opacity(0.1))
            } else if loadFailed {
                Image(systemName: "photo")
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color.secondary.opacity(0.1))
            } else {
                Color.secondary.opacity(0.1)
            }
        }
        .task {
            guard uiImage == nil, !isLoading else { return }
            isLoading = true
            defer { isLoading = false }

            do {
                let url = try await CloudKitManager.shared.downloadImage(entryImage)
                if let data = try? Data(contentsOf: url),
                   let loaded = UIImage(data: data) {
                    uiImage = loaded
                } else {
                    loadFailed = true
                }
            } catch {
                loadFailed = true
            }
        }
    }
}

13. Relationships with CKRecord.Reference

CloudKit relationships are implemented through CKRecord.Reference — essentially a foreign key pointing to another record's ID.

Reference Actions

When you create a reference, you specify a CKRecord.ReferenceAction:

.deleteSelf — When the parent (referenced) record is deleted, THIS record is also deleted. CloudKit handles this server-side.

.none — When the parent record is deleted, this record keeps its reference. The reference becomes dangling — it still points to a record that no longer exists.

EntryImage  ──(.deleteSelf)──→  JournalEntry
   When JournalEntry is deleted, all its EntryImages are auto-deleted.

JournalEntry  ──(.none)──→  Category
   When Category is deleted, entries keep their reference (dangling).
   Entries are NOT deleted. You handle orphaned entries in your app logic.

Creating References

// One-to-many: Entry belongs to a Category
let categoryRef = CKRecord.Reference(
    recordID: category.id,
    action: .none  // Don't delete entries when category is deleted
)
entryRecord[CloudKitConfig.EntryField.category] = categoryRef

// One-to-many: Image belongs to an Entry (cascade delete)
let entryRef = CKRecord.Reference(
    recordID: entry.id,
    action: .deleteSelf  // Delete images when entry is deleted
)
imageRecord[CloudKitConfig.ImageField.entry] = entryRef

Querying by Reference

To find all entries in a category:

let categoryRef = CKRecord.Reference(recordID: categoryID, action: .none)
let predicate = NSPredicate(format: "%K == %@", CloudKitConfig.EntryField.category, categoryRef)
let query = CKQuery(
    recordType: CloudKitConfig.RecordType.journalEntry,
    predicate: predicate
)

No JOINs — Manual Relationship Loading

CloudKit has no JOIN operation. To load an entry with its category name and images, you need multiple queries:

extension CloudKitManager {

    /// Fetches an entry with its category and images in parallel.
    func fetchEntryWithRelationships(
        id: CKRecord.ID
    ) async throws -> (JournalEntry, JournalCategory?, [EntryImage]) {
        // Kick off all three fetches in parallel
        async let entryTask = fetchEntry(id: id)
        async let imagesTask = fetchImageMetadata(for: JournalEntry(
            id: id, title: "", body: "", mood: .neutral,
            createdAt: .now, modifiedAt: .now, isFavorite: false,
            categoryID: nil,
            record: CKRecord(recordType: "JournalEntry", recordID: id)
        ))

        guard let entry = try await entryTask else {
            throw CloudKitAppError.invalidRecord
        }

        let images = try await imagesTask

        // Fetch category only if the entry has one
        var category: JournalCategory?
        if let categoryID = entry.categoryID {
            let categoryRecord = try? await CloudKitConfig.privateDB.record(for: categoryID)
            if let categoryRecord {
                category = JournalCategory(record: categoryRecord)
            }
        }

        return (entry, category, images)
    }

    /// Handles deletion of a category by clearing references on its entries.
    func deleteCategoryAndOrphanEntries(_ category: JournalCategory) async throws {
        // First, find all entries in this category
        let entries = try await fetchEntries(in: category)

        // Clear the category reference on each entry
        for var entry in entries {
            entry.categoryID = nil
            _ = try await updateEntry(entry)
        }

        // Now safe to delete the category
        try await deleteCategory(category)
    }
}

Many-to-Many Relationships

If you needed a many-to-many relationship (e.g., entries can have multiple tags), you'd use a junction record type, like we showed for NoteTag in the previous guide. CloudKit doesn't support array-of-references as a field type for queryable relationships — you need explicit junction records.


14. The Public Database

Key Differences from Private DB

Aspect Private DB Public DB
Who can read Only the account owner Anyone (even without iCloud)
Who can write Only the account owner Authenticated users only
Storage quota User's iCloud quota App's CloudKit quota (Apple-provided)
Custom zones Yes No (default zone only)
Atomic operations Yes (custom zones) No
CKServerChangeToken sync Yes (custom zones) No
Security roles N/A (owner-only) World, Authenticated, Creator

Public Database Use Cases in CloudJournal

Writing to the Public Database

extension CloudKitManager {

    /// Publishes a journal entry to the public database.
    /// Creates a separate copy — not linked to the private entry.
    func publishEntry(_ entry: JournalEntry, authorName: String) async throws -> CKRecord {
        // Public DB uses default zone only — no custom zoneID
        let publicRecordID = CKRecord.ID(recordName: UUID().uuidString)
        let publicRecord = CKRecord(recordType: "PublishedEntry", recordID: publicRecordID)

        publicRecord["title"] = entry.title as CKRecordValue
        publicRecord["body"] = entry.body as CKRecordValue
        publicRecord["mood"] = entry.mood.rawValue as CKRecordValue
        publicRecord["authorName"] = authorName as CKRecordValue
        publicRecord["publishedAt"] = Date() as CKRecordValue

        return try await CloudKitConfig.publicDB.save(publicRecord)
    }

    /// Fetches published entries from the public database.
    /// No authentication required for reads.
    func fetchPublishedEntries(limit: Int = 50) async throws -> [CKRecord] {
        let query = CKQuery(
            recordType: "PublishedEntry",
            predicate: NSPredicate(value: true)
        )
        query.sortDescriptors = [NSSortDescriptor(key: "publishedAt", ascending: false)]

        // No zoneID for public DB — uses default zone automatically
        let (results, _) = try await CloudKitConfig.publicDB.records(
            matching: query,
            desiredKeys: ["title", "mood", "authorName", "publishedAt"],
            resultsLimit: limit
        )

        return results.compactMap { _, result in
            guard case .success(let record) = result else { return nil }
            return record
        }
    }
}

Security Roles

Configure these in CloudKit Dashboard → Schema → Record Types → [PublishedEntry] → Security:


15. Subscriptions & Push Notifications

Subscriptions let CloudKit notify your app when data changes, even when the app is in the background. This is how you implement real-time sync.

Three Subscription Types

Type Triggers On Best For
CKQuerySubscription Records matching a predicate change Public DB alerts (e.g., "new published entry")
CKRecordZoneSubscription Any record in a zone changes Private DB sync (all changes in your zone)
CKDatabaseSubscription Any zone in a database changes Shared DB sync (detecting new shares)

Setting Up Subscriptions

extension CloudKitManager {

    // MARK: - Subscription Registration

    /// Register all subscriptions. Call once at app launch.
    /// Subscriptions are persistent — CloudKit remembers them.
    /// Calling this when subscriptions already exist is a no-op.
    func registerSubscriptions() async {
        await registerPrivateZoneSubscription()
        await registerSharedDatabaseSubscription()
    }

    /// Subscribe to ALL changes in our custom zone.
    /// This fires whenever any record is created, modified, or deleted.
    private func registerPrivateZoneSubscription() async {
        let subscriptionID = "journal-zone-changes"

        // Check if already registered (avoid duplicates)
        if let _ = try? await CloudKitConfig.privateDB.subscription(for: subscriptionID) {
            logger.info("Private zone subscription already exists")
            return
        }

        let subscription = CKRecordZoneSubscription(
            zoneID: CloudKitConfig.zoneID,
            subscriptionID: subscriptionID
        )

        // Configure the notification
        let notificationInfo = CKSubscription.NotificationInfo()
        notificationInfo.shouldSendContentAvailable = true  // Silent push (no user-visible alert)
        // If you want a visible notification:
        // notificationInfo.alertBody = "Your journal was updated on another device"
        // notificationInfo.shouldBadge = true
        subscription.notificationInfo = notificationInfo

        do {
            try await CloudKitConfig.privateDB.save(subscription)
            logger.info("Registered private zone subscription")
        } catch {
            logger.error("Failed to register private subscription: \(error)")
        }
    }

    /// Subscribe to changes in the shared database.
    /// Fires when someone shares a new record with you or updates a shared record.
    private func registerSharedDatabaseSubscription() async {
        let subscriptionID = "shared-db-changes"

        if let _ = try? await CloudKitConfig.sharedDB.subscription(for: subscriptionID) {
            return
        }

        let subscription = CKDatabaseSubscription(subscriptionID: subscriptionID)
        let notificationInfo = CKSubscription.NotificationInfo()
        notificationInfo.shouldSendContentAvailable = true
        subscription.notificationInfo = notificationInfo

        do {
            try await CloudKitConfig.sharedDB.save(subscription)
            logger.info("Registered shared database subscription")
        } catch {
            logger.error("Failed to register shared subscription: \(error)")
        }
    }
}

Handling Push Notifications

CloudKit subscriptions deliver silent push notifications. You need an AppDelegate to receive them:

// AppDelegate.swift
import UIKit
import CloudKit

class AppDelegate: NSObject, UIApplicationDelegate {

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        // Register for remote notifications (required for CloudKit subscriptions)
        application.registerForRemoteNotifications()
        return true
    }

    func application(
        _ application: UIApplication,
        didReceiveRemoteNotification userInfo: [AnyHashable: Any],
        fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
    ) {
        // CloudKit sends a notification when subscribed data changes
        let notification = CKNotification(fromRemoteNotificationDictionary: userInfo)

        guard let notification else {
            completionHandler(.noData)
            return
        }

        Task {
            switch notification.notificationType {
            case .recordZone:
                // Our custom zone changed — fetch incremental changes
                await CloudKitManager.shared.fetchZoneChanges()
                completionHandler(.newData)
            case .database:
                // Shared database changed — someone shared/unshared something
                await CloudKitManager.shared.fetchDatabaseChanges()
                completionHandler(.newData)
            default:
                completionHandler(.noData)
            }
        }
    }

    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        // Without push registration, subscriptions can't deliver notifications.
        // App must fall back to polling on foreground.
        print("Push registration failed: \(error). Subscriptions won't deliver.")
    }
}

Wire it into SwiftUI:

// In CloudJournalApp.swift
@main
struct CloudJournalApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    // ... rest of app
}

16. Incremental Sync with Server Change Tokens

This is the most important sync mechanism in CloudKit. Instead of re-querying all records, you ask "what changed since last time?" using a CKServerChangeToken.

How Token-Based Sync Works

First Sync (token = nil):
  App  ──→  "Give me everything in JournalZone"  ──→  CloudKit
  App  ←──  [all records] + token_A               ←──  CloudKit
  App saves token_A locally

Later Sync (token = token_A):
  App  ──→  "What changed since token_A?"          ──→  CloudKit
  App  ←──  [only changed/deleted records] + token_B ←──  CloudKit
  App saves token_B locally

Token Expired:
  App  ──→  "What changed since token_X?"          ──→  CloudKit
  App  ←──  Error: .changeTokenExpired              ←──  CloudKit
  App discards token_X, does a full sync with nil token

Implementing Zone Change Fetching

extension CloudKitManager {

    // MARK: - Token Storage

    private var zoneTokenKey: String {
        "ckZoneChangeToken_\(CloudKitConfig.zoneName)"
    }

    private func loadZoneToken() -> CKServerChangeToken? {
        guard let data = UserDefaults.standard.data(forKey: zoneTokenKey) else {
            return nil
        }
        return try? NSKeyedUnarchiver.unarchivedObject(
            ofClass: CKServerChangeToken.self,
            from: data
        )
    }

    private func saveZoneToken(_ token: CKServerChangeToken) {
        if let data = try? NSKeyedArchiver.archivedData(
            withRootObject: token,
            requiringSecureCoding: true
        ) {
            UserDefaults.standard.set(data, forKey: zoneTokenKey)
        }
    }

    private func clearZoneToken() {
        UserDefaults.standard.removeObject(forKey: zoneTokenKey)
    }

    // MARK: - Fetch Zone Changes (Incremental Sync)

    /// Fetches only the records that changed since the last sync.
    /// Call this when you receive a push notification or on foreground.
    func fetchZoneChanges() async {
        let previousToken = loadZoneToken()

        var config = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
        config.previousServerChangeToken = previousToken

        let operation = CKFetchRecordZoneChangesOperation(
            recordZoneIDs: [CloudKitConfig.zoneID],
            configurationsByRecordZoneID: [CloudKitConfig.zoneID: config]
        )

        var changedRecords: [CKRecord] = []
        var deletedRecordIDs: [(CKRecord.ID, CKRecord.RecordType)] = []

        // Called for each modified/created record
        operation.recordWasChangedBlock = { _, result in
            if case .success(let record) = result {
                changedRecords.append(record)
            }
        }

        // Called for each deleted record
        operation.recordWithIDWasDeletedBlock = { recordID, recordType in
            deletedRecordIDs.append((recordID, recordType ?? ""))
        }

        // Called when the zone fetch completes (with new token)
        operation.recordZoneFetchResultBlock = { [weak self] _, result in
            switch result {
            case .success(let (newToken, _, _)):
                self?.saveZoneToken(newToken)
                self?.logger.info("Zone sync complete. Changed: \(changedRecords.count), Deleted: \(deletedRecordIDs.count)")

            case .failure(let error):
                if let ckError = error as? CKError, ckError.code == .changeTokenExpired {
                    // Token expired — reset and do full sync
                    self?.logger.warning("Change token expired, resetting for full sync")
                    self?.clearZoneToken()
                    Task { await self?.fetchZoneChanges() }
                } else {
                    self?.logger.error("Zone fetch failed: \(error)")
                }
            }
        }

        // Called when the entire operation completes
        operation.fetchRecordZoneChangesResultBlock = { [weak self] result in
            Task { @MainActor in
                self?.processIncomingChanges(
                    changed: changedRecords,
                    deleted: deletedRecordIDs
                )
            }
        }

        CloudKitConfig.privateDB.add(operation)
    }

    // MARK: - Fetch Database Changes (Shared DB)

    private var dbTokenKey: String { "ckDatabaseChangeToken" }

    func fetchDatabaseChanges() async {
        let previousToken: CKServerChangeToken? = {
            guard let data = UserDefaults.standard.data(forKey: dbTokenKey) else { return nil }
            return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: data)
        }()

        let operation = CKFetchDatabaseChangesOperation(
            previousServerChangeToken: previousToken
        )

        var changedZoneIDs: [CKRecordZone.ID] = []

        operation.recordZoneWithIDChangedBlock = { zoneID in
            changedZoneIDs.append(zoneID)
        }

        operation.fetchDatabaseChangesResultBlock = { [weak self] result in
            switch result {
            case .success(let (newToken, _)):
                if let data = try? NSKeyedArchiver.archivedData(
                    withRootObject: newToken,
                    requiringSecureCoding: true
                ) {
                    UserDefaults.standard.set(data, forKey: self?.dbTokenKey ?? "")
                }
                // Fetch record-level changes for each changed zone
                for zoneID in changedZoneIDs {
                    Task {
                        await self?.fetchSharedZoneChanges(zoneID: zoneID)
                    }
                }
            case .failure(let error):
                self?.logger.error("Database changes fetch failed: \(error)")
            }
        }

        CloudKitConfig.sharedDB.add(operation)
    }

    private func fetchSharedZoneChanges(zoneID: CKRecordZone.ID) async {
        // Same pattern as fetchZoneChanges but targeting sharedDB and this specific zone
        logger.info("Fetching shared zone changes for: \(zoneID.zoneName)")
    }

    // MARK: - Process Changes

    @MainActor
    private func processIncomingChanges(
        changed: [CKRecord],
        deleted: [(CKRecord.ID, CKRecord.RecordType)]
    ) {
        // Post a notification so ViewModels can update their state
        NotificationCenter.default.post(
            name: .cloudKitDataChanged,
            object: nil,
            userInfo: [
                "changed": changed,
                "deleted": deleted.map(\.0)
            ]
        )
    }
}

extension Notification.Name {
    static let cloudKitDataChanged = Notification.Name("cloudKitDataChanged")
}

Part 4 — Advanced Topics


17. CKSyncEngine — The Modern Sync Architecture (iOS 17+)

CKSyncEngine was introduced at WWDC 2023 and is Apple's recommended way to sync CloudKit data. It replaces the manual subscription + change token + operation approach from Section 15-16 with a higher-level, event-driven API.

Why CKSyncEngine?

Before CKSyncEngine, you had to manually: 1. Create zones 2. Register subscriptions 3. Handle push notifications 4. Fetch changes with CKFetchRecordZoneChangesOperation 5. Track CKServerChangeToken manually 6. Upload pending changes with CKModifyRecordsOperation 7. Handle all errors, retries, and conflicts yourself 8. Serialize and restore sync state across app launches

CKSyncEngine handles ALL of this for you. You just: 1. Tell it what records need saving/deleting (pending changes) 2. Handle events it sends you (records fetched, records saved, errors) 3. Persist its state serialization between launches

Architecture Overview

┌─────────────────────────────────────────────┐
│               Your App Code                  │
│                                              │
│  ┌──────────────────────────────────────┐   │
│  │  SyncedJournalStore                   │   │
│  │  (CKSyncEngineDelegate)              │   │
│  │                                       │   │
│  │  • Provides records for upload        │   │
│  │  • Handles fetched records            │   │
│  │  • Handles errors                     │   │
│  │  • Persists local data                │   │
│  └───────────────┬──────────────────────┘   │
│                  │                           │
│  ┌───────────────┴──────────────────────┐   │
│  │  CKSyncEngine                         │   │
│  │  (Apple's framework)                  │   │
│  │                                       │   │
│  │  • Manages zones automatically        │   │
│  │  • Handles subscriptions              │   │
│  │  • Tracks change tokens               │   │
│  │  • Schedules sends & fetches          │   │
│  │  • Retries transient errors           │   │
│  │  • Coalesces network operations       │   │
│  └───────────────┬──────────────────────┘   │
│                  │                           │
└──────────────────┼──────────────────────────┘
                   │
          ┌────────┴────────┐
          │  CloudKit Server │
          └─────────────────┘

Building SyncedJournalStore

This is the central class that owns the sync engine and your local data.

// Sync/SyncedJournalStore.swift
import CloudKit
import os

private let logger = Logger(subsystem: "com.yourcompany.cloudjournal", category: "SyncEngine")

/// The local data structure persisted to disk.
/// Contains all journal entries + the sync engine's serialized state.
struct LocalJournalData: Codable {
    var entries: [String: SyncableEntry] = [:]  // keyed by record name
    var categories: [String: SyncableCategory] = [:]
    var stateSerialization: CKSyncEngine.State.Serialization?
}

/// A journal entry that stores the last known CKRecord for conflict resolution.
struct SyncableEntry: Codable, Identifiable {
    var id: String  // record name (UUID string)
    var title: String
    var body: String
    var mood: String
    var createdAt: Date
    var modifiedAt: Date
    var isFavorite: Bool
    var categoryID: String?  // record name of the category

    /// Archived CKRecord data — preserves system fields for upload.
    var lastKnownRecordData: Data?

    var lastKnownRecord: CKRecord? {
        get {
            guard let data = lastKnownRecordData else { return nil }
            return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKRecord.self, from: data)
        }
        set {
            lastKnownRecordData = newValue.flatMap {
                try? NSKeyedArchiver.archivedData(withRootObject: $0, requiringSecureCoding: true)
            }
        }
    }

    var recordID: CKRecord.ID {
        CKRecord.ID(recordName: id, zoneID: CloudKitConfig.zoneID)
    }

    /// Write fields onto a CKRecord for upload.
    func populateRecord(_ record: CKRecord) {
        record[CloudKitConfig.EntryField.title] = title as CKRecordValue
        record[CloudKitConfig.EntryField.body] = body as CKRecordValue
        record[CloudKitConfig.EntryField.mood] = mood as CKRecordValue
        record[CloudKitConfig.EntryField.createdAt] = createdAt as CKRecordValue
        record[CloudKitConfig.EntryField.modifiedAt] = modifiedAt as CKRecordValue
        record[CloudKitConfig.EntryField.isFavorite] = Int64(isFavorite ? 1 : 0) as CKRecordValue

        if let categoryID {
            record[CloudKitConfig.EntryField.category] = CKRecord.Reference(
                recordID: CKRecord.ID(recordName: categoryID, zoneID: CloudKitConfig.zoneID),
                action: .none
            )
        } else {
            record[CloudKitConfig.EntryField.category] = nil
        }
    }

    /// Merge a server record into this entry.
    /// Uses modifiedAt as the conflict resolution signal.
    mutating func mergeFromServerRecord(_ record: CKRecord) {
        let serverDate = record[CloudKitConfig.EntryField.modifiedAt] as? Date ?? .distantPast

        if serverDate > modifiedAt {
            // Server wins — adopt remote values
            title = record[CloudKitConfig.EntryField.title] as? String ?? title
            body = record[CloudKitConfig.EntryField.body] as? String ?? body
            mood = record[CloudKitConfig.EntryField.mood] as? String ?? mood
            modifiedAt = serverDate
            isFavorite = (record[CloudKitConfig.EntryField.isFavorite] as? Int64 ?? 0) == 1
            categoryID = (record[CloudKitConfig.EntryField.category] as? CKRecord.Reference)?
                .recordID.recordName
        }
        // else: local wins — keep current values; sync engine will re-upload
    }

    /// Update lastKnownRecord if the new record is more recent.
    mutating func setLastKnownRecordIfNewer(_ record: CKRecord) {
        guard let existing = lastKnownRecord else {
            lastKnownRecord = record
            return
        }

        // Compare modification dates — keep the newer one
        if let existingDate = existing.modificationDate,
           let newDate = record.modificationDate,
           newDate > existingDate {
            lastKnownRecord = record
        } else if lastKnownRecord == nil {
            lastKnownRecord = record
        }
    }
}

struct SyncableCategory: Codable, Identifiable {
    var id: String
    var name: String
    var colorHex: String
    var icon: String
    var sortOrder: Int
    var lastKnownRecordData: Data?

    var lastKnownRecord: CKRecord? {
        get {
            guard let data = lastKnownRecordData else { return nil }
            return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKRecord.self, from: data)
        }
        set {
            lastKnownRecordData = newValue.flatMap {
                try? NSKeyedArchiver.archivedData(withRootObject: $0, requiringSecureCoding: true)
            }
        }
    }

    var recordID: CKRecord.ID {
        CKRecord.ID(recordName: id, zoneID: CloudKitConfig.zoneID)
    }
}

The Sync Engine Delegate

// Sync/SyncedJournalStore+Engine.swift
import CloudKit

@Observable
final class SyncedJournalStore: CKSyncEngineDelegate {

    // MARK: - Properties

    private(set) var localData: LocalJournalData
    private var _syncEngine: CKSyncEngine?
    private let dataURL: URL
    private let automaticallySync: Bool

    var syncEngine: CKSyncEngine {
        if _syncEngine == nil {
            initializeSyncEngine()
        }
        return _syncEngine!
    }

    // MARK: - Initialization

    init(
        automaticallySync: Bool = true,
        dataURL: URL? = nil
    ) {
        // Load persisted local data or start fresh
        let url = dataURL ?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("journal-data.json")
        self.dataURL = url
        self.automaticallySync = automaticallySync

        if let data = try? Data(contentsOf: url),
           let decoded = try? JSONDecoder().decode(LocalJournalData.self, from: data) {
            self.localData = decoded
        } else {
            self.localData = LocalJournalData()
        }
    }

    // MARK: - Sync Engine Setup

    private func initializeSyncEngine() {
        var config = CKSyncEngine.Configuration(
            database: CloudKitConfig.privateDB,
            stateSerialization: localData.stateSerialization,
            delegate: self
        )
        config.automaticallySync = automaticallySync
        _syncEngine = CKSyncEngine(config)
    }

    // MARK: - Persistence

    func persistLocalData() throws {
        let data = try JSONEncoder().encode(localData)
        try data.write(to: dataURL)
    }

    // MARK: - CKSyncEngineDelegate — The Central Event Handler

    func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
        switch event {

        case .stateUpdate(let event):
            // The sync engine's internal state changed — persist it so we can
            // resume from this point after app relaunch.
            localData.stateSerialization = event.stateSerialization
            try? persistLocalData()

        case .accountChange(let event):
            handleAccountChange(event)

        case .fetchedDatabaseChanges(let event):
            handleFetchedDatabaseChanges(event)

        case .fetchedRecordZoneChanges(let event):
            handleFetchedRecordZoneChanges(event)

        case .sentRecordZoneChanges(let event):
            handleSentRecordZoneChanges(event)

        case .sentDatabaseChanges:
            // Our app only has one zone, so we don't need to handle this
            break

        case .willFetchChanges, .willFetchRecordZoneChanges,
             .didFetchRecordZoneChanges, .didFetchChanges,
             .willSendChanges, .didSendChanges:
            // These are lifecycle hooks — useful for progress UI
            break

        @unknown default:
            logger.info("Unknown sync engine event: \(event)")
        }
    }

    // MARK: - Provide Records for Upload

    /// Called by the sync engine when it's ready to send changes.
    /// You must return a batch of CKRecords to upload.
    func nextRecordZoneChangeBatch(
        _ context: CKSyncEngine.SendChangesContext,
        syncEngine: CKSyncEngine
    ) async -> CKSyncEngine.RecordZoneChangeBatch? {

        let scope = context.options.scope
        let pendingChanges = syncEngine.state.pendingRecordZoneChanges.filter { scope.contains($0) }

        return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pendingChanges) { recordID in
            let recordName = recordID.recordName

            // Check entries
            if let entry = self.localData.entries[recordName] {
                let record = entry.lastKnownRecord
                    ?? CKRecord(
                        recordType: CloudKitConfig.RecordType.journalEntry,
                        recordID: recordID
                    )
                entry.populateRecord(record)
                return record
            }

            // Check categories
            if let category = self.localData.categories[recordName] {
                let record = category.lastKnownRecord
                    ?? CKRecord(
                        recordType: CloudKitConfig.RecordType.category,
                        recordID: recordID
                    )
                record[CloudKitConfig.CategoryField.name] = category.name as CKRecordValue
                record[CloudKitConfig.CategoryField.colorHex] = category.colorHex as CKRecordValue
                record[CloudKitConfig.CategoryField.icon] = category.icon as CKRecordValue
                record[CloudKitConfig.CategoryField.sortOrder] = Int64(category.sortOrder) as CKRecordValue
                return record
            }

            // Record was deleted locally before upload — remove stale pending change
            syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
            return nil
        }
    }
}

Handling Events

// Sync/SyncedJournalStore+Events.swift
import CloudKit

extension SyncedJournalStore {

    // MARK: - Account Changes

    func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) {
        switch event.changeType {
        case .signIn:
            // Re-upload all local data to the new account
            let entryChanges: [CKSyncEngine.PendingRecordZoneChange] = localData.entries.values.map {
                .saveRecord($0.recordID)
            }
            let categoryChanges: [CKSyncEngine.PendingRecordZoneChange] = localData.categories.values.map {
                .saveRecord($0.recordID)
            }

            // Ensure the zone exists
            syncEngine.state.add(pendingDatabaseChanges: [
                .saveZone(CKRecordZone(zoneName: CloudKitConfig.zoneName))
            ])
            syncEngine.state.add(pendingRecordZoneChanges: entryChanges + categoryChanges)

        case .switchAccounts, .signOut:
            // Wipe local data — it belongs to the old account
            localData = LocalJournalData()
            try? persistLocalData()
            initializeSyncEngine()  // Fresh engine with no pending state

        @unknown default:
            logger.info("Unknown account change: \(event)")
        }
    }

    // MARK: - Fetched Database Changes

    func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) {
        // Handle zone deletions (rare — someone deleted the zone externally)
        for deletion in event.deletions {
            if deletion.zoneID == CloudKitConfig.zoneID {
                logger.warning("Our zone was deleted externally! Clearing local data.")
                localData.entries.removeAll()
                localData.categories.removeAll()
                try? persistLocalData()
            }
        }
    }

    // MARK: - Fetched Record Zone Changes (Core Sync Logic)

    func handleFetchedRecordZoneChanges(_ event: CKSyncEngine.Event.FetchedRecordZoneChanges) {
        // Process modifications (new or updated records from the server)
        for modification in event.modifications {
            let record = modification.record
            let recordName = record.recordID.recordName

            switch record.recordType {
            case CloudKitConfig.RecordType.journalEntry:
                var entry = localData.entries[recordName] ?? SyncableEntry(
                    id: recordName,
                    title: "",
                    body: "",
                    mood: Mood.neutral.rawValue,
                    createdAt: Date(),
                    modifiedAt: Date(),
                    isFavorite: false,
                    categoryID: nil
                )
                entry.mergeFromServerRecord(record)
                entry.setLastKnownRecordIfNewer(record)
                localData.entries[recordName] = entry

            case CloudKitConfig.RecordType.category:
                var category = localData.categories[recordName] ?? SyncableCategory(
                    id: recordName,
                    name: "",
                    colorHex: "#007AFF",
                    icon: "folder",
                    sortOrder: 0
                )
                // For categories, server always wins (simple overwrite)
                category.name = record[CloudKitConfig.CategoryField.name] as? String ?? category.name
                category.colorHex = record[CloudKitConfig.CategoryField.colorHex] as? String ?? category.colorHex
                category.icon = record[CloudKitConfig.CategoryField.icon] as? String ?? category.icon
                category.sortOrder = Int(record[CloudKitConfig.CategoryField.sortOrder] as? Int64 ?? 0)
                category.setLastKnownRecordIfNewer(record)
                localData.categories[recordName] = category

            default:
                logger.info("Unknown record type fetched: \(record.recordType)")
            }
        }

        // Process deletions
        for deletion in event.deletions {
            let recordName = deletion.recordID.recordName
            localData.entries.removeValue(forKey: recordName)
            localData.categories.removeValue(forKey: recordName)
        }

        // Persist changes if anything happened
        if !event.modifications.isEmpty || !event.deletions.isEmpty {
            try? persistLocalData()
        }
    }

    // MARK: - Sent Record Zone Changes (Upload Results)

    func handleSentRecordZoneChanges(_ event: CKSyncEngine.Event.SentRecordZoneChanges) {
        var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = []
        var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = []

        // Update locally cached server records for successful saves
        for savedRecord in event.savedRecords {
            let recordName = savedRecord.recordID.recordName

            if var entry = localData.entries[recordName] {
                entry.setLastKnownRecordIfNewer(savedRecord)
                localData.entries[recordName] = entry
            }
            if var category = localData.categories[recordName] {
                category.setLastKnownRecordIfNewer(savedRecord)
                localData.categories[recordName] = category
            }
        }

        // Handle failures
        for failedSave in event.failedRecordSaves {
            let recordName = failedSave.record.recordID.recordName

            switch failedSave.error.code {

            case .serverRecordChanged:
                // CONFLICT — the server has a newer version.
                // Merge the server record and re-queue the save.
                guard let serverRecord = failedSave.error.serverRecord else { continue }

                if var entry = localData.entries[recordName] {
                    entry.mergeFromServerRecord(serverRecord)
                    entry.setLastKnownRecordIfNewer(serverRecord)
                    localData.entries[recordName] = entry
                    newPendingRecordZoneChanges.append(
                        .saveRecord(failedSave.record.recordID)
                    )
                }

            case .zoneNotFound:
                // Zone was deleted — recreate it and retry
                let zone = CKRecordZone(zoneID: failedSave.record.recordID.zoneID)
                newPendingDatabaseChanges.append(.saveZone(zone))
                newPendingRecordZoneChanges.append(
                    .saveRecord(failedSave.record.recordID)
                )
                // Clear cached system fields since the zone is new
                localData.entries[recordName]?.lastKnownRecord = nil
                localData.categories[recordName]?.lastKnownRecord = nil

            case .unknownItem:
                // Record was deleted on server — clear cached system fields and re-upload
                newPendingRecordZoneChanges.append(
                    .saveRecord(failedSave.record.recordID)
                )
                localData.entries[recordName]?.lastKnownRecord = nil
                localData.categories[recordName]?.lastKnownRecord = nil

            case .networkFailure, .networkUnavailable, .zoneBusy,
                 .serviceUnavailable, .notAuthenticated, .operationCancelled:
                // Transient errors — sync engine retries automatically
                logger.debug("Transient error, sync engine will retry: \(failedSave.error.code)")

            default:
                logger.fault("Unhandled save error for \(recordName): \(failedSave.error)")
            }
        }

        // Add any new pending changes
        if !newPendingDatabaseChanges.isEmpty {
            syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges)
        }
        if !newPendingRecordZoneChanges.isEmpty {
            syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges)
        }

        try? persistLocalData()
    }
}

extension SyncableCategory {
    mutating func setLastKnownRecordIfNewer(_ record: CKRecord) {
        guard let existing = lastKnownRecord,
              let existingDate = existing.modificationDate,
              let newDate = record.modificationDate,
              newDate <= existingDate
        else {
            lastKnownRecord = record
            return
        }
    }
}

Local CRUD Operations (Queue Changes for Sync)

// Sync/SyncedJournalStore+CRUD.swift

extension SyncedJournalStore {

    // MARK: - Create Entry

    func createEntry(title: String, body: String, mood: Mood, categoryID: String? = nil) {
        let id = UUID().uuidString
        let now = Date()

        let entry = SyncableEntry(
            id: id,
            title: title,
            body: body,
            mood: mood.rawValue,
            createdAt: now,
            modifiedAt: now,
            isFavorite: false,
            categoryID: categoryID
        )

        localData.entries[id] = entry
        try? persistLocalData()

        // Queue for upload — sync engine handles the rest
        syncEngine.state.add(pendingDatabaseChanges: [
            .saveZone(CKRecordZone(zoneName: CloudKitConfig.zoneName))
        ])
        syncEngine.state.add(pendingRecordZoneChanges: [
            .saveRecord(entry.recordID)
        ])
    }

    // MARK: - Update Entry

    func updateEntry(_ entry: SyncableEntry) {
        var modified = entry
        modified.modifiedAt = Date()
        localData.entries[entry.id] = modified
        try? persistLocalData()

        syncEngine.state.add(pendingRecordZoneChanges: [
            .saveRecord(entry.recordID)
        ])
    }

    // MARK: - Delete Entry

    func deleteEntry(id: String) {
        guard let entry = localData.entries.removeValue(forKey: id) else { return }
        try? persistLocalData()

        syncEngine.state.add(pendingRecordZoneChanges: [
            .deleteRecord(entry.recordID)
        ])
    }

    // MARK: - Create Category

    func createCategory(name: String, colorHex: String, icon: String) {
        let id = UUID().uuidString
        let category = SyncableCategory(
            id: id,
            name: name,
            colorHex: colorHex,
            icon: icon,
            sortOrder: localData.categories.count
        )

        localData.categories[id] = category
        try? persistLocalData()

        syncEngine.state.add(pendingDatabaseChanges: [
            .saveZone(CKRecordZone(zoneName: CloudKitConfig.zoneName))
        ])
        syncEngine.state.add(pendingRecordZoneChanges: [
            .saveRecord(category.recordID)
        ])
    }

    // MARK: - Delete Category

    func deleteCategory(id: String) {
        guard let category = localData.categories.removeValue(forKey: id) else { return }
        try? persistLocalData()

        syncEngine.state.add(pendingRecordZoneChanges: [
            .deleteRecord(category.recordID)
        ])
    }

    // MARK: - Computed Properties for UI

    var entries: [SyncableEntry] {
        localData.entries.values
            .sorted { $0.modifiedAt > $1.modifiedAt }
    }

    var categories: [SyncableCategory] {
        localData.categories.values
            .sorted { $0.sortOrder < $1.sortOrder }
    }

    // MARK: - Wipe All Data

    func deleteAllLocalData() throws {
        localData = LocalJournalData()
        try persistLocalData()
        initializeSyncEngine()
    }

    func deleteAllServerData() async throws {
        let zoneID = CKRecordZone.ID(zoneName: CloudKitConfig.zoneName)
        syncEngine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)])
        try await syncEngine.sendChanges()
    }
}

SwiftUI Integration with CKSyncEngine

// Views/SyncedJournalListView.swift
import SwiftUI

struct SyncedJournalListView: View {
    @State var store: SyncedJournalStore
    @State private var showingNewEntry = false
    @State private var newTitle = ""
    @State private var newBody = ""
    @State private var selectedMood: Mood = .neutral

    var body: some View {
        NavigationStack {
            List {
                ForEach(store.entries) { entry in
                    NavigationLink(value: entry.id) {
                        VStack(alignment: .leading, spacing: 4) {
                            HStack {
                                Image(systemName: Mood(rawValue: entry.mood)?.icon ?? "face.dashed")
                                Text(entry.title.isEmpty ? "Untitled" : entry.title)
                                    .font(.headline)
                                if entry.isFavorite {
                                    Image(systemName: "heart.fill")
                                        .foregroundStyle(.red)
                                        .font(.caption)
                                }
                            }
                            Text(entry.body)
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                                .lineLimit(2)
                            Text(entry.modifiedAt.formatted(.relative(presentation: .named)))
                                .font(.caption2)
                                .foregroundStyle(.tertiary)
                        }
                    }
                }
                .onDelete { indexSet in
                    for index in indexSet {
                        let entry = store.entries[index]
                        store.deleteEntry(id: entry.id)
                    }
                }
            }
            .navigationTitle("Journal")
            .navigationDestination(for: String.self) { entryID in
                if let entry = store.localData.entries[entryID] {
                    EntryDetailView(store: store, entry: entry)
                }
            }
            .toolbar {
                Button {
                    showingNewEntry = true
                } label: {
                    Label("New Entry", systemImage: "square.and.pencil")
                }
            }
            .sheet(isPresented: $showingNewEntry) {
                NavigationStack {
                    Form {
                        TextField("Title", text: $newTitle)
                        TextEditor(text: $newBody)
                            .frame(minHeight: 100)
                        Picker("Mood", selection: $selectedMood) {
                            ForEach(Mood.allCases) { mood in
                                Label(mood.displayName, systemImage: mood.icon)
                                    .tag(mood)
                            }
                        }
                    }
                    .navigationTitle("New Entry")
                    .toolbar {
                        ToolbarItem(placement: .cancellationAction) {
                            Button("Cancel") { showingNewEntry = false }
                        }
                        ToolbarItem(placement: .confirmationAction) {
                            Button("Save") {
                                store.createEntry(
                                    title: newTitle,
                                    body: newBody,
                                    mood: selectedMood
                                )
                                newTitle = ""
                                newBody = ""
                                selectedMood = .neutral
                                showingNewEntry = false
                            }
                        }
                    }
                }
            }
        }
    }
}

struct EntryDetailView: View {
    let store: SyncedJournalStore
    @State var entry: SyncableEntry

    var body: some View {
        Form {
            TextField("Title", text: $entry.title)
            TextEditor(text: $entry.body)
                .frame(minHeight: 200)
            Picker("Mood", selection: Binding(
                get: { Mood(rawValue: entry.mood) ?? .neutral },
                set: { entry.mood = $0.rawValue }
            )) {
                ForEach(Mood.allCases) { mood in
                    Label(mood.displayName, systemImage: mood.icon).tag(mood)
                }
            }
            Toggle("Favorite", isOn: $entry.isFavorite)
        }
        .navigationTitle(entry.title.isEmpty ? "Untitled" : entry.title)
        .onDisappear {
            store.updateEntry(entry)
        }
    }
}

18. Encrypted Fields (encryptedValues)

Starting with iOS 15, CloudKit supports encrypted fields via encryptedValues. These fields are encrypted at rest on Apple's servers — even Apple cannot read them. The encryption key is derived from the user's iCloud account.

When to Use Encrypted Fields

Limitations

Using Encrypted Fields

// Writing encrypted values
let record = CKRecord(recordType: "JournalEntry", recordID: recordID)

// Regular (cleartext) fields — can be queried and indexed
record["title"] = "My Therapy Session" as CKRecordValue
record["modifiedAt"] = Date() as CKRecordValue

// Encrypted fields — stored encrypted at rest on Apple's servers
record.encryptedValues["body"] = "Today we discussed childhood memories..." as CKRecordValue
record.encryptedValues["mood"] = "anxious" as CKRecordValue
record.encryptedValues["therapistNotes"] = "Patient showed improvement" as CKRecordValue

// Reading encrypted values
let body = record.encryptedValues["body"] as? String
let mood = record.encryptedValues["mood"] as? String

Integrating with Our CKSyncEngine Store

Modify SyncableEntry.populateRecord to use encrypted fields for sensitive data:

extension SyncableEntry {
    /// Populates a CKRecord with a mix of cleartext and encrypted fields.
    /// Title and dates are cleartext (queryable); body and mood are encrypted.
    func populateRecordEncrypted(_ record: CKRecord) {
        // Cleartext (queryable + indexable)
        record[CloudKitConfig.EntryField.title] = title as CKRecordValue
        record[CloudKitConfig.EntryField.createdAt] = createdAt as CKRecordValue
        record[CloudKitConfig.EntryField.modifiedAt] = modifiedAt as CKRecordValue
        record[CloudKitConfig.EntryField.isFavorite] = Int64(isFavorite ? 1 : 0) as CKRecordValue

        if let categoryID {
            record[CloudKitConfig.EntryField.category] = CKRecord.Reference(
                recordID: CKRecord.ID(recordName: categoryID, zoneID: CloudKitConfig.zoneID),
                action: .none
            )
        }

        // Encrypted (private, not queryable)
        record.encryptedValues["body"] = body as CKRecordValue
        record.encryptedValues["mood"] = mood as CKRecordValue
    }

    /// Initialize from a record that uses encrypted fields.
    init?(encryptedRecord record: CKRecord) {
        guard record.recordType == CloudKitConfig.RecordType.journalEntry,
              let title = record[CloudKitConfig.EntryField.title] as? String,
              let createdAt = record[CloudKitConfig.EntryField.createdAt] as? Date,
              let modifiedAt = record[CloudKitConfig.EntryField.modifiedAt] as? Date
        else { return nil }

        self.id = record.recordID.recordName
        self.title = title
        self.createdAt = createdAt
        self.modifiedAt = modifiedAt
        self.isFavorite = (record[CloudKitConfig.EntryField.isFavorite] as? Int64 ?? 0) == 1
        self.categoryID = (record[CloudKitConfig.EntryField.category] as? CKRecord.Reference)?
            .recordID.recordName

        // Read encrypted fields
        self.body = record.encryptedValues["body"] as? String ?? ""
        self.mood = record.encryptedValues["mood"] as? String ?? Mood.neutral.rawValue
    }
}

19. CloudKit Sharing with CKShare

CloudKit Sharing lets a user share specific records from their private database with other iCloud users. The owner creates a CKShare linked to a root record, and participants access the shared record through their shared database.

How Sharing Works

1. Owner creates a CKShare for a JournalEntry
2. Owner saves both the entry + CKShare atomically
3. CKShare generates a URL (e.g., https://www.icloud.com/share/...)
4. Owner sends the URL to a friend (via Messages, email, etc.)
5. Friend opens the URL → system calls your app
6. Your app calls container.accept(shareMetadata)
7. The entry now appears in the friend's sharedCloudDatabase

Creating a Share

extension CloudKitManager {

    // MARK: - Sharing

    /// Share a journal entry. Returns the share and its URL.
    func shareEntry(
        _ entry: JournalEntry,
        permission: CKShare.ParticipantPermission = .readOnly
    ) async throws -> (CKShare, URL) {
        // Create a CKShare linked to the entry's record
        let share = CKShare(rootRecord: entry.record)

        // Set metadata that appears in the share invitation
        share[CKShare.SystemFieldKey.title] = entry.title as CKRecordValue
        share.publicPermission = permission

        // Save both atomically — the share must live in the same zone as the record
        let result = try await CloudKitConfig.privateDB.modifyRecords(
            saving: [entry.record, share],
            deleting: [],
            savePolicy: .changedKeys,
            atomically: true
        )

        // Verify both saved successfully
        for (_, saveResult) in result.saveResults {
            if case .failure(let error) = saveResult {
                throw error
            }
        }

        guard let shareURL = share.url else {
            throw CloudKitAppError.invalidRecord
        }

        return (share, shareURL)
    }

    /// Accept an incoming share (called when user opens a share URL).
    func acceptShare(metadata: CKShare.Metadata) async throws {
        try await CloudKitConfig.container.accept(metadata)
    }

    /// Fetch all entries shared with the current user.
    func fetchSharedEntries() async throws -> [JournalEntry] {
        var entries: [JournalEntry] = []

        // Each share owner creates a separate zone in the shared DB
        let zones = try await CloudKitConfig.sharedDB.allRecordZones()

        for zone in zones {
            let query = CKQuery(
                recordType: CloudKitConfig.RecordType.journalEntry,
                predicate: NSPredicate(value: true)
            )

            let (results, _) = try await CloudKitConfig.sharedDB.records(
                matching: query,
                inZoneWith: zone.zoneID
            )

            let zoneEntries = results.compactMap { _, result -> JournalEntry? in
                guard case .success(let record) = result else { return nil }
                return JournalEntry(record: record)
            }
            entries.append(contentsOf: zoneEntries)
        }

        return entries
    }

    /// Stop sharing (owner only). Removes all participant access.
    func stopSharing(_ share: CKShare) async throws {
        try await CloudKitConfig.privateDB.modifyRecords(
            saving: [],
            deleting: [share.recordID],
            savePolicy: .changedKeys,
            atomically: false
        )
    }
}

Handling Share URLs in SwiftUI

// In CloudJournalApp.swift
@main
struct CloudJournalApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    @State private var cloudKit = CloudKitManager.shared

    var body: some Scene {
        WindowGroup {
            // ... your root view
            .onOpenURL { url in
                Task {
                    do {
                        let metadata = try await CloudKitConfig.container.fetchShareMetadata(for: url)
                        try await cloudKit.acceptShare(metadata: metadata)
                    } catch {
                        print("Failed to accept share: \(error)")
                    }
                }
            }
        }
    }
}

Add to Info.plist to enable share URL handling:

<key>CKSharingSupported</key>
<true/>

UICloudSharingController (System Share UI)

Apple provides a built-in sharing UI that handles participant management, permissions, and share link copying:

// Views/ShareSheetView.swift
import SwiftUI
import CloudKit

struct CloudSharingSheet: UIViewControllerRepresentable {
    let share: CKShare
    let container: CKContainer

    func makeUIViewController(context: Context) -> UICloudSharingController {
        let controller = UICloudSharingController(share: share, container: container)
        controller.availablePermissions = [.allowReadOnly, .allowReadWrite]
        return controller
    }

    func updateUIViewController(_ uiViewController: UICloudSharingController, context: Context) {}
}

20. Conflict Resolution Strategies

When two devices edit the same record before either syncs, a conflict occurs. CloudKit rejects the second save with .serverRecordChanged, providing both the server record and (in some APIs) the ancestor record.

The Three Strategies

1. Last Writer Wins — Simple: overwrite the server with whatever the client has. Risks data loss if two devices edit different fields.

2. Server Wins — Accept the server version, discard local changes. Safe but frustrating for users.

3. Field-Level Merge (Custom) — Compare each field individually. Use timestamps to decide which version wins per field. Best user experience but most complex.

Implementing Field-Level Merge

Our SyncableEntry.mergeFromServerRecord already implements a timestamp-based merge. Here's a more detailed version:

extension SyncableEntry {
    /// Three-way merge using the server record and local state.
    /// For each field: if local is newer, keep local; if server is newer, adopt server.
    mutating func threeWayMerge(serverRecord: CKRecord) {
        let serverModified = serverRecord[CloudKitConfig.EntryField.modifiedAt] as? Date ?? .distantPast
        let localModified = modifiedAt

        // Title: latest modification wins
        if serverModified > localModified {
            title = serverRecord[CloudKitConfig.EntryField.title] as? String ?? title
        }

        // Body: if both changed, concatenate with a separator (preserves both edits)
        let serverBody = serverRecord[CloudKitConfig.EntryField.body] as? String ?? ""
        if serverBody != body && serverModified > localModified {
            body = serverBody
        }

        // Mood: latest wins (it's a single choice)
        if serverModified > localModified {
            mood = serverRecord[CloudKitConfig.EntryField.mood] as? String ?? mood
        }

        // isFavorite: latest wins (toggle state)
        if serverModified > localModified {
            isFavorite = (serverRecord[CloudKitConfig.EntryField.isFavorite] as? Int64 ?? 0) == 1
        }

        // Always update modifiedAt to the newer of the two
        modifiedAt = max(localModified, serverModified)
    }
}

With CKSyncEngine (Our Approach)

In the handleSentRecordZoneChanges method, when we get .serverRecordChanged:

  1. Extract the server record from failedSave.error.serverRecord
  2. Merge the server record into our local data
  3. Update the lastKnownRecord to the server version (this resets the changeTag)
  4. Re-queue the save — the sync engine will call nextRecordZoneChangeBatch again with our merged data

This is exactly what our implementation does in Section 17.


21. Offline-First Architecture with SwiftData

For production apps, you need a local persistence layer so the app works without network access. SwiftData (or Core Data) serves as the local cache, and CloudKit (or CKSyncEngine) syncs it with the cloud.

The Architecture

┌─────────────────────────────────────┐
│          SwiftUI Views              │  ← Reads from SwiftData
├─────────────────────────────────────┤
│   @Observable ViewModel / Store     │  ← Writes to SwiftData + queues sync
├─────────────────────────────────────┤
│         SwiftData (Local)           │  ← Offline persistence
├─────────────────────────────────────┤
│     CKSyncEngine (Background)      │  ← Syncs SwiftData ↔ CloudKit
├─────────────────────────────────────┤
│       CloudKit (Remote)             │
└─────────────────────────────────────┘

SwiftData Cache Models

// LocalCache/CachedEntry.swift
import SwiftData
import Foundation

@Model
class CachedEntry {
    @Attribute(.unique) var recordName: String
    var title: String
    var body: String
    var mood: String
    var createdAt: Date
    var modifiedAt: Date
    var isFavorite: Bool
    var categoryRecordName: String?

    /// Tracks whether this record has unsaved local changes.
    var isDirty: Bool
    /// Tracks whether this record was deleted locally but not yet synced.
    var isPendingDeletion: Bool

    init(
        recordName: String,
        title: String,
        body: String,
        mood: String,
        createdAt: Date,
        modifiedAt: Date,
        isFavorite: Bool = false,
        categoryRecordName: String? = nil,
        isDirty: Bool = false,
        isPendingDeletion: Bool = false
    ) {
        self.recordName = recordName
        self.title = title
        self.body = body
        self.mood = mood
        self.createdAt = createdAt
        self.modifiedAt = modifiedAt
        self.isFavorite = isFavorite
        self.categoryRecordName = categoryRecordName
        self.isDirty = isDirty
        self.isPendingDeletion = isPendingDeletion
    }
}

@Model
class CachedCategory {
    @Attribute(.unique) var recordName: String
    var name: String
    var colorHex: String
    var icon: String
    var sortOrder: Int
    var isDirty: Bool

    init(
        recordName: String,
        name: String,
        colorHex: String = "#007AFF",
        icon: String = "folder",
        sortOrder: Int = 0,
        isDirty: Bool = false
    ) {
        self.recordName = recordName
        self.name = name
        self.colorHex = colorHex
        self.icon = icon
        self.sortOrder = sortOrder
        self.isDirty = isDirty
    }
}

SwiftData + CKSyncEngine Integration Pattern

The key insight: SwiftData is the source of truth for the UI. CKSyncEngine syncs SwiftData with CloudKit in the background. All user interactions write to SwiftData first, then queue a sync.

// LocalCache/OfflineJournalStore.swift
import SwiftData
import CloudKit
import Network

@Observable
final class OfflineJournalStore {
    private let modelContext: ModelContext
    private let syncStore: SyncedJournalStore
    private let networkMonitor = NWPathMonitor()

    private(set) var isOnline = false

    init(modelContainer: ModelContainer) {
        self.modelContext = ModelContext(modelContainer)
        self.syncStore = SyncedJournalStore()
        startNetworkMonitoring()
    }

    private func startNetworkMonitoring() {
        networkMonitor.pathUpdateHandler = { [weak self] path in
            self?.isOnline = path.status == .satisfied
        }
        networkMonitor.start(queue: .global(qos: .utility))
    }

    // MARK: - Local CRUD (Always Works, Even Offline)

    func createEntry(title: String, body: String, mood: Mood) {
        let recordName = UUID().uuidString
        let now = Date()

        // 1. Save to SwiftData (immediate, local)
        let cached = CachedEntry(
            recordName: recordName,
            title: title,
            body: body,
            mood: mood.rawValue,
            createdAt: now,
            modifiedAt: now,
            isDirty: true
        )
        modelContext.insert(cached)
        try? modelContext.save()

        // 2. Queue for CloudKit sync (will happen when online)
        syncStore.createEntry(title: title, body: body, mood: mood)
    }

    func updateEntry(_ cached: CachedEntry) {
        cached.modifiedAt = Date()
        cached.isDirty = true
        try? modelContext.save()

        // Queue sync
        if let syncable = syncStore.localData.entries[cached.recordName] {
            var updated = syncable
            updated.title = cached.title
            updated.body = cached.body
            updated.mood = cached.mood
            updated.isFavorite = cached.isFavorite
            syncStore.updateEntry(updated)
        }
    }

    func deleteEntry(_ cached: CachedEntry) {
        let recordName = cached.recordName
        cached.isPendingDeletion = true
        try? modelContext.save()

        syncStore.deleteEntry(id: recordName)

        // Remove from SwiftData after queueing sync
        modelContext.delete(cached)
        try? modelContext.save()
    }

    // MARK: - Fetch (Always from Local SwiftData)

    func fetchEntries() throws -> [CachedEntry] {
        let descriptor = FetchDescriptor<CachedEntry>(
            predicate: #Predicate { !$0.isPendingDeletion },
            sortBy: [SortDescriptor(\.modifiedAt, order: .reverse)]
        )
        return try modelContext.fetch(descriptor)
    }

    func fetchFavorites() throws -> [CachedEntry] {
        let descriptor = FetchDescriptor<CachedEntry>(
            predicate: #Predicate { $0.isFavorite && !$0.isPendingDeletion },
            sortBy: [SortDescriptor(\.modifiedAt, order: .reverse)]
        )
        return try modelContext.fetch(descriptor)
    }
}

22. Error Handling & Retry Patterns

CloudKit errors are delivered as CKError instances. Proper error handling is critical — CloudKit is a networked service and will fail regularly.

CKError Categories

extension CKError {

    /// Transient errors that will likely succeed on retry.
    var isTransient: Bool {
        switch code {
        case .networkFailure,
             .networkUnavailable,
             .serviceUnavailable,
             .requestRateLimited,
             .zoneBusy:
            return true
        default:
            return false
        }
    }

    /// The delay CloudKit recommends before retrying.
    /// Returns nil if no retry is recommended.
    var suggestedRetryDelay: TimeInterval? {
        userInfo[CKErrorRetryAfterKey] as? TimeInterval
    }

    /// Whether this error should be shown to the user.
    var requiresUserAction: Bool {
        switch code {
        case .notAuthenticated,
             .quotaExceeded,
             .permissionFailure,
             .managedAccountRestricted:
            return true
        default:
            return false
        }
    }

    /// A user-readable description.
    var userFacingMessage: String {
        switch code {
        case .notAuthenticated:
            return "Please sign in to iCloud in Settings."
        case .quotaExceeded:
            return "Your iCloud storage is full. Free up space in Settings > iCloud."
        case .networkUnavailable:
            return "No internet connection. Changes will sync when you're back online."
        case .networkFailure:
            return "Network error. Please try again."
        case .serviceUnavailable:
            return "iCloud is temporarily unavailable. Try again later."
        case .permissionFailure:
            return "You don't have permission to perform this action."
        case .changeTokenExpired:
            return "Sync data is outdated. Refreshing..."
        case .serverRecordChanged:
            return "This record was modified on another device. Merging changes..."
        default:
            return "Something went wrong. Please try again."
        }
    }
}

Retry with Exponential Backoff

/// Retries a CloudKit operation with exponential backoff.
/// Respects CloudKit's suggested retry delay when available.
func withCloudKitRetry<T>(
    maxAttempts: Int = 3,
    operation: () async throws -> T
) async throws -> T {
    var lastError: Error?

    for attempt in 0..<maxAttempts {
        do {
            return try await operation()
        } catch let error as CKError where error.isTransient {
            lastError = error

            // Use CloudKit's suggested delay, or exponential backoff
            let baseDelay = error.suggestedRetryDelay ?? pow(2.0, Double(attempt))
            let jitter = Double.random(in: 0..<1)  // Prevent thundering herd
            let delay = baseDelay + jitter

            logger.info("Retry \(attempt + 1)/\(maxAttempts) after \(delay)s: \(error.code.rawValue)")
            try await Task.sleep(for: .seconds(delay))
        } catch {
            // Non-transient error — don't retry
            throw error
        }
    }

    throw lastError!
}

// Usage:
// let entries = try await withCloudKitRetry {
//     try await cloudKit.fetchAllEntries()
// }

Handling Partial Failures in Batch Operations

extension CloudKitManager {
    /// Batch save with comprehensive error handling.
    func robustBatchSave(records: [CKRecord]) async throws -> [CKRecord] {
        let result = try await CloudKitConfig.privateDB.modifyRecords(
            saving: records,
            deleting: [],
            savePolicy: .changedKeys,
            atomically: false  // Allow partial success
        )

        var saved: [CKRecord] = []
        var conflicts: [(CKRecord, CKError)] = []
        var permanentFailures: [(CKRecord.ID, CKError)] = []

        for (recordID, saveResult) in result.saveResults {
            switch saveResult {
            case .success(let record):
                saved.append(record)

            case .failure(let error):
                guard let ckError = error as? CKError else {
                    permanentFailures.append((recordID, CKError(.internalError)))
                    continue
                }

                if ckError.code == .serverRecordChanged {
                    let record = records.first { $0.recordID == recordID }!
                    conflicts.append((record, ckError))
                } else if ckError.isTransient {
                    // Will be retried by caller or sync engine
                    logger.debug("Transient failure for \(recordID.recordName), will retry")
                } else {
                    permanentFailures.append((recordID, ckError))
                }
            }
        }

        // Log results
        if !conflicts.isEmpty {
            logger.warning("\(conflicts.count) conflicts detected during batch save")
        }
        if !permanentFailures.isEmpty {
            logger.error("\(permanentFailures.count) permanent failures in batch save")
            for (id, error) in permanentFailures {
                logger.error("  \(id.recordName): \(error.code.rawValue)")
            }
        }

        return saved
    }
}

23. Performance Optimization

CloudKit Performance Rules

1. Always use desiredKeys — Never fetch fields you don't need. This is especially critical for records with CKAsset fields. Fetching 50 records with images downloads 50 images; fetching 50 records with desiredKeys: ["title", "mood"] transfers only text.

2. Batch everything — One modifyRecords call with 50 records is vastly faster than 50 individual save calls.

3. Use incremental sync, not full queriesCKFetchRecordZoneChangesOperation with a saved token fetches only what changed. Re-querying everything is O(n) on every sync; incremental is O(delta).

4. Use resultsLimit — Never fetch unbounded result sets. CloudKit may return up to 400 records per page. Set a reasonable limit for your UI needs.

5. Cache aggressively — Store records locally (SwiftData, JSON file). Only fetch from CloudKit when the server notifies you of changes.

6. Use operation QoS wisely — Set .userInitiated for operations the user is waiting for, .utility for background sync.

// Setting QoS on an operation
let operation = CKFetchRecordZoneChangesOperation(...)
operation.qualityOfService = .utility  // Background sync
CloudKitConfig.privateDB.add(operation)

// vs.
let fetchOp = CKQueryOperation(query: query)
fetchOp.qualityOfService = .userInitiated  // User is waiting for results

7. Avoid querying in loops — If you need related records, batch-fetch them by ID rather than querying one at a time.

// BAD — N+1 query problem
for entry in entries {
    if let categoryID = entry.categoryID {
        let category = try await db.record(for: categoryID)  // N network calls!
    }
}

// GOOD — Single batch fetch
let categoryIDs = entries.compactMap(\.categoryID)
let uniqueIDs = Array(Set(categoryIDs))
let categories = try await fetchRecords(ids: uniqueIDs)  // 1 network call

24. Testing CloudKit Apps

The Testing Pyramid for CloudKit

       /\
      /  \     Integration tests (real CloudKit, dev environment)
     /    \    — Slow, requires iCloud account
    /──────\
   /        \   Unit tests with mocks
  /          \  — Fast, no network, deterministic
 /────────────\
/              \ CKSyncEngine tests (built-in test support)
/________________\ — Simulated multi-device scenarios

Protocol-Based Mocking

Abstract CKDatabase behind a protocol so you can swap in a mock for testing:

// Testing/CloudKitDatabaseProtocol.swift
import CloudKit

protocol CloudKitDatabaseProtocol: Sendable {
    func save(_ record: CKRecord) async throws -> CKRecord
    func record(for recordID: CKRecord.ID) async throws -> CKRecord
    func deleteRecord(withID: CKRecord.ID) async throws
    func records(
        matching query: CKQuery,
        inZoneWith zoneID: CKRecordZone.ID?,
        desiredKeys: [CKRecord.FieldKey]?,
        resultsLimit: Int
    ) async throws -> ([(CKRecord.ID, Result<CKRecord, Error>)], CKQueryOperation.Cursor?)
}

// CKDatabase already conforms — just declare it
extension CKDatabase: CloudKitDatabaseProtocol {}

Mock Database

// Testing/MockCloudKitDatabase.swift
final class MockCloudKitDatabase: CloudKitDatabaseProtocol, @unchecked Sendable {
    var records: [CKRecord.ID: CKRecord] = [:]
    var errorToThrow: Error?
    var saveCallCount = 0
    var deleteCallCount = 0

    func save(_ record: CKRecord) async throws -> CKRecord {
        saveCallCount += 1
        if let error = errorToThrow { throw error }
        records[record.recordID] = record
        return record
    }

    func record(for recordID: CKRecord.ID) async throws -> CKRecord {
        if let error = errorToThrow { throw error }
        guard let record = records[recordID] else {
            throw CKError(.unknownItem)
        }
        return record
    }

    func deleteRecord(withID recordID: CKRecord.ID) async throws {
        deleteCallCount += 1
        if let error = errorToThrow { throw error }
        records.removeValue(forKey: recordID)
    }

    func records(
        matching query: CKQuery,
        inZoneWith zoneID: CKRecordZone.ID?,
        desiredKeys: [CKRecord.FieldKey]?,
        resultsLimit: Int
    ) async throws -> ([(CKRecord.ID, Result<CKRecord, Error>)], CKQueryOperation.Cursor?) {
        if let error = errorToThrow { throw error }
        let matching = records.values
            .filter { $0.recordType == query.recordType }
            .prefix(resultsLimit)
            .map { ($0.recordID, Result<CKRecord, Error>.success($0)) }
        return (Array(matching), nil)
    }
}

Unit Tests

// Tests/JournalEntryTests.swift
import Testing
import CloudKit
@testable import CloudJournal

@Suite("Journal Entry Model Tests")
struct JournalEntryTests {

    @Test("Create entry from CKRecord")
    func createFromRecord() {
        let recordID = CKRecord.ID(
            recordName: "test-123",
            zoneID: CloudKitConfig.zoneID
        )
        let record = CKRecord(
            recordType: CloudKitConfig.RecordType.journalEntry,
            recordID: recordID
        )
        record["title"] = "Test Entry" as CKRecordValue
        record["body"] = "Test body" as CKRecordValue
        record["mood"] = "happy" as CKRecordValue
        record["createdAt"] = Date() as CKRecordValue
        record["modifiedAt"] = Date() as CKRecordValue
        record["isFavorite"] = Int64(1) as CKRecordValue

        let entry = JournalEntry(record: record)
        #expect(entry != nil)
        #expect(entry?.title == "Test Entry")
        #expect(entry?.mood == .happy)
        #expect(entry?.isFavorite == true)
    }

    @Test("Entry with missing required fields returns nil")
    func missingFields() {
        let record = CKRecord(recordType: CloudKitConfig.RecordType.journalEntry)
        // Missing title, body, dates
        let entry = JournalEntry(record: record)
        #expect(entry == nil)
    }

    @Test("Round-trip: model → record → model preserves data")
    func roundTrip() {
        let record = JournalEntry.newRecord()
        let now = Date()
        record["title"] = "Round Trip" as CKRecordValue
        record["body"] = "Testing round trip" as CKRecordValue
        record["mood"] = "excited" as CKRecordValue
        record["createdAt"] = now as CKRecordValue
        record["modifiedAt"] = now as CKRecordValue
        record["isFavorite"] = Int64(0) as CKRecordValue

        guard let original = JournalEntry(record: record) else {
            Issue.record("Failed to create entry")
            return
        }

        let exported = original.toRecord()
        guard let restored = JournalEntry(record: exported) else {
            Issue.record("Failed to restore entry")
            return
        }

        #expect(restored.title == original.title)
        #expect(restored.body == original.body)
        #expect(restored.mood == original.mood)
        #expect(restored.isFavorite == original.isFavorite)
    }
}

@Suite("Merge Conflict Tests")
struct MergeConflictTests {

    @Test("Server-wins when server has newer modifiedAt")
    func serverWins() {
        var local = SyncableEntry(
            id: "test-1",
            title: "Local Title",
            body: "Local Body",
            mood: "happy",
            createdAt: Date(),
            modifiedAt: Date.now.addingTimeInterval(-60),  // 1 minute ago
            isFavorite: false,
            categoryID: nil
        )

        let serverRecord = CKRecord(
            recordType: CloudKitConfig.RecordType.journalEntry,
            recordID: CKRecord.ID(recordName: "test-1", zoneID: CloudKitConfig.zoneID)
        )
        serverRecord["title"] = "Server Title" as CKRecordValue
        serverRecord["body"] = "Server Body" as CKRecordValue
        serverRecord["modifiedAt"] = Date.now as CKRecordValue  // Now (newer)

        local.mergeFromServerRecord(serverRecord)

        #expect(local.title == "Server Title")
        #expect(local.body == "Server Body")
    }

    @Test("Local-wins when local has newer modifiedAt")
    func localWins() {
        var local = SyncableEntry(
            id: "test-1",
            title: "Local Title",
            body: "Local Body",
            mood: "happy",
            createdAt: Date(),
            modifiedAt: Date.now,  // Now (newer)
            isFavorite: false,
            categoryID: nil
        )

        let serverRecord = CKRecord(
            recordType: CloudKitConfig.RecordType.journalEntry,
            recordID: CKRecord.ID(recordName: "test-1", zoneID: CloudKitConfig.zoneID)
        )
        serverRecord["title"] = "Server Title" as CKRecordValue
        serverRecord["modifiedAt"] = Date.now.addingTimeInterval(-60) as CKRecordValue  // Older

        local.mergeFromServerRecord(serverRecord)

        #expect(local.title == "Local Title")  // Local kept
    }
}

CKSyncEngine Testing

CKSyncEngine supports automated testing by allowing you to create multiple instances that act as separate "devices":

@Suite("CKSyncEngine Multi-Device Tests")
struct SyncEngineTests {

    func newTestStore() -> SyncedJournalStore {
        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent("Journal-\(UUID().uuidString).json")
        return SyncedJournalStore(automaticallySync: false, dataURL: url)
    }

    @Test("Create on device A, sync to device B")
    func basicSync() async throws {
        let deviceA = newTestStore()
        let deviceB = newTestStore()

        // Device A creates an entry
        deviceA.createEntry(title: "Hello from A", body: "Test", mood: .happy)

        // Device A uploads
        try await deviceA.syncEngine.sendChanges()

        // Device B downloads
        try await deviceB.syncEngine.fetchChanges()

        // Verify Device B received it
        #expect(deviceB.entries.count == 1)
        #expect(deviceB.entries.first?.title == "Hello from A")
    }
}

25. SwiftData + CloudKit Automatic Sync

If you want CloudKit sync with minimal manual code, SwiftData can sync with CloudKit automatically through NSPersistentCloudKitContainer under the hood. This is the easiest approach but offers less control than CKSyncEngine.

Setup

// Models for SwiftData + CloudKit auto-sync
import SwiftData

@Model
class AutoSyncEntry {
    var title: String
    var body: String
    var mood: String
    var createdAt: Date
    var modifiedAt: Date
    var isFavorite: Bool

    init(title: String, body: String, mood: String = "neutral") {
        self.title = title
        self.body = body
        self.mood = mood
        self.createdAt = Date()
        self.modifiedAt = Date()
        self.isFavorite = false
    }
}
// CloudJournalApp.swift with automatic sync
import SwiftUI
import SwiftData

@main
struct CloudJournalApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: AutoSyncEntry.self, isAutosaveEnabled: true) {
            // Configure for CloudKit sync
            let schema = Schema([AutoSyncEntry.self])
            let config = ModelConfiguration(
                schema: schema,
                cloudKitDatabase: .private("iCloud.com.yourcompany.cloudjournal")
            )
            return try ModelContainer(for: schema, configurations: [config])
        }
    }
}

Limitations of Automatic Sync

Feature SwiftData Auto-Sync CKSyncEngine (Manual)
Setup complexity Minimal Significant
Conflict resolution Last-writer-wins (automatic) Custom (you implement)
Encrypted fields Not supported Full control
Public database Not supported Supported
Sharing (CKShare) Limited support Full control
Offline support Automatic (Core Data local store) You implement
Progress/status callbacks None Full event stream
Schema control Automatic from @Model Manual in Dashboard
Custom zones Automatic (one per model) You choose

Recommendation: Use SwiftData auto-sync for simple apps where last-writer-wins is acceptable. Use CKSyncEngine for apps that need custom conflict resolution, encrypted fields, sharing, or public database access.


26. Production Deployment & Schema Management

Development vs. Production Environments

CloudKit has two separate environments:

Development: - Schema is auto-created when you save records with new fields - Data is separate from production - Only available to development-signed builds - Schema can be freely modified

Production: - Schema must be explicitly deployed from Development - Data is real user data - Available to App Store builds - Schema is append-only — you can add fields and record types but never remove or rename them

Deploying Your Schema

  1. Open CloudKit Dashboard
  2. Select your container
  3. Click Deploy Schema Changes...
  4. Review the diff — this shows what will be added to Production
  5. Click Deploy

Schema Migration Strategies

Since you can't remove or rename fields in Production, plan ahead:

Adding a field: Simply add it. Old records won't have the field — handle nil gracefully in your parsing code.

// Safe: handles both old records (no "location" field) and new ones
let location = record["location"] as? CLLocation  // nil for old records

Renaming a field: Add the new field, migrate data in your app, stop reading the old field. You can't delete the old field from the schema, but you can stop using it.

Changing a field type: Add a new field with the desired type. Migrate data. The old field remains in the schema forever but is unused.

Pre-Launch Checklist

Schema:
  [ ] All record types created in Dashboard
  [ ] All required indexes configured (Queryable, Sortable, Searchable)
  [ ] Schema deployed to Production
  [ ] Security roles configured for public DB record types

Entitlements:
  [ ] iCloud capability with CloudKit service
  [ ] Push Notifications capability
  [ ] Background Modes → Remote notifications
  [ ] Container identifier matches Dashboard

Code:
  [ ] Account status checked before all CloudKit operations
  [ ] Account change notification observed (.CKAccountChanged)
  [ ] Custom zone creation is idempotent (safe to call on every launch)
  [ ] Subscriptions registered (idempotent)
  [ ] Push notifications registered (application.registerForRemoteNotifications)
  [ ] CKServerChangeToken persisted and restored across launches
  [ ] Token expiration handled (reset + full sync)
  [ ] All CKError codes handled (at minimum: retryable vs. permanent)
  [ ] Retry with exponential backoff for transient errors
  [ ] CKErrorRetryAfterKey respected
  [ ] Conflict resolution implemented (serverRecordChanged)
  [ ] desiredKeys used for list views (skip heavy fields)
  [ ] resultsLimit set on all queries
  [ ] Pagination implemented with CKQueryOperation.Cursor
  [ ] Offline mode works (local cache + dirty tracking)
  [ ] Network monitoring with NWPathMonitor
  [ ] Sync on reconnect

Testing:
  [ ] Unit tests with mock database
  [ ] Integration tests against Development environment
  [ ] Multi-device sync tested (two simulators or device + simulator)
  [ ] Offline → online transition tested
  [ ] Account sign-out/sign-in tested
  [ ] Large dataset tested (1000+ records)
  [ ] Quota exceeded error handling tested

Dashboard Monitoring:
  [ ] Telemetry tab reviewed for errors
  [ ] Rate limiting not triggered under normal usage
  [ ] Public DB quota within limits

Monitoring in Production

The CloudKit Dashboard's Telemetry tab shows:

Check this regularly after launch. A spike in .requestRateLimited errors means you're hitting CloudKit too aggressively. A spike in .serverRecordChanged means your conflict resolution strategy needs tuning.


Appendices


Appendix A: Complete CloudJournal App File Structure

CloudJournal/
├── CloudJournalApp.swift              // @main, AppDelegate, .onOpenURL
├── AppDelegate.swift                  // Push notification handling
│
├── Config/
│   └── CloudKitConfig.swift           // Container, zone, record type constants
│
├── Models/
│   ├── JournalEntry.swift             // Domain model + CloudKit conversion
│   ├── Category.swift                 // Domain model + CloudKit conversion
│   ├── EntryImage.swift               // Domain model + CloudKit conversion
│   └── Mood.swift                     // Mood enum
│
├── CloudKit/
│   ├── CloudKitManager.swift          // Core CloudKit operations (non-sync-engine path)
│   └── CloudKitAppError.swift         // Custom error types
│
├── Sync/
│   ├── SyncedJournalStore.swift       // CKSyncEngine delegate + local data
│   ├── SyncedJournalStore+Events.swift // Event handlers
│   ├── SyncedJournalStore+CRUD.swift  // Local CRUD + sync queueing
│   ├── SyncableEntry.swift            // Codable entry with merge logic
│   └── SyncableCategory.swift         // Codable category
│
├── LocalCache/
│   ├── CachedEntry.swift              // SwiftData @Model
│   ├── CachedCategory.swift           // SwiftData @Model
│   └── OfflineJournalStore.swift      // SwiftData + sync coordination
│
├── Views/
│   ├── SyncedJournalListView.swift    // Main list view
│   ├── EntryDetailView.swift          // Entry editor
│   ├── AsyncCKImage.swift             // Lazy image loader
│   ├── CloudKitUnavailableView.swift  // Account status guard
│   └── CloudSharingSheet.swift        // UICloudSharingController wrapper
│
├── Errors/
│   ├── CKError+Extensions.swift       // Retry logic, user messages
│   └── RetryHelpers.swift             // withCloudKitRetry
│
└── Tests/
    ├── JournalEntryTests.swift        // Model tests
    ├── MergeConflictTests.swift       // Conflict resolution tests
    └── SyncEngineTests.swift          // Multi-device sync tests

Appendix B: CKError Code Quick Reference

Error Code Name Retryable Action
0 internalError No Log and report
1 partialFailure Varies Check per-record errors
2 networkUnavailable Yes Retry when online
3 networkFailure Yes Retry with backoff
4 badContainer No Fix container ID
5 serviceUnavailable Yes Retry later
6 requestRateLimited Yes Respect CKErrorRetryAfterKey
7 missingEntitlement No Check Xcode capabilities
9 notAuthenticated No Show "sign in" UI
10 permissionFailure No Check security roles
11 unknownItem No Record/zone doesn't exist
12 invalidArguments No Fix your code
14 serverRecordChanged Special Resolve conflict, retry
15 serverRejectedRequest No Check request validity
16 assetFileNotFound No Re-create asset file
17 assetFileModified No Re-create asset file
21 changeTokenExpired Special Reset token, full sync
22 batchRequestFailed Varies One record failed in atomic batch
23 zoneBusy Yes Retry with backoff
25 zoneNotFound No Create zone first
26 limitExceeded No Reduce batch size
27 userDeletedZone No User deleted via Settings
28 tooManyParticipants No Reduce share participants
29 alreadyShared No Record already has a CKShare
31 managedAccountRestricted No MDM/parental restriction
33 quotaExceeded No Show "storage full" UI
35 operationCancelled Yes Retry if appropriate

Appendix C: CloudKit Limits & Quotas

Per-Operation Limits

Limit Value
Records per save/delete operation 400
Records per query result page 400 (default 100)
Record size (metadata + small fields) ~1 MB
Asset file size 250 MB
Subscription limit per database 4,000
Record types per container ~200
Fields per record type ~200
Indexes per record type Varies (avoid excessive)

Private Database Quotas

The private database uses the user's iCloud storage (5 GB free with Apple ID, up to 12 TB with paid plans). Your app competes with Photos, Drive, and backups for this space.

Public Database Quotas (Your App's Quota)

Apple provides free quotas that scale with your user count:

Resource Per-User Allowance Base
Asset storage 250 MB/user 1 GB
Database storage 25 MB/user 100 MB
Data transfer 50 MB/user/day 500 MB/day
Requests 40/user/second 200/second

These are generous for most apps. Monitor usage in the CloudKit Dashboard → Telemetry.


Guide written for iOS 17+ / Swift 5.9+ / Xcode 15+ / CloudKit with CKSyncEngine. Last updated 2026.