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
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.
CloudKit is the right choice when:
| 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 |
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) │
└─────────────────────────────────────────────────────────┘
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 │ │ │ │
│ │ └───────────┘ │ │ │ │ │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
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")
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
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)
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
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)
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
CloudJournaliCloud.com.yourcompany.cloudjournal (replace yourcompany with your actual identifier)
- Make sure the checkbox next to your container is selectedThis automatically:
- Adds the iCloud entitlement to your app
- Creates the CloudKit container on Apple's servers
- Configures your provisioning profile
CloudKit subscriptions deliver changes via silent push notifications. You need this capability even if your app doesn't show visible notifications.
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>
The CloudKit Dashboard is your admin console for managing schemas, viewing data, and monitoring usage.
iCloud.com.yourcompany.cloudjournalCreate 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.
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.
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.
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+)
}
// 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."
}
}
}
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()
}
}
}
}
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()
}
}
}
}
}
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.
// 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.
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
)
}
}
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)")
}
}
}
Now we have our models and zone. Let's create journal entries.
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
)
// 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."
}
}
}
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
}
}
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.
| 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.
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)
}
}
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)
}
}
}
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)
}
}
}
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)
}
}
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
)
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)")
}
}
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")
}
}
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.
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 (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)])
}
}
}
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)
}
}
We created a custom zone in Section 5 but haven't explored why it's so important. Here's the full picture.
| 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.
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.
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)")
}
}
Data field: For small binary data under ~1 MB. Loaded every time the record is fetched unless you use desiredKeys to exclude it. Good for: thumbnails, small icons, serialized data.CKAsset: For anything larger. Stored separately from record metadata. Streamed on demand. Good for: photos, documents, audio, video.CKAsset requires a file URL — you can't create one from Data directly. You must write to a temp file first.
// 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
}
}
}
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
}
}
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)
}
}
// 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
}
}
}
}
CloudKit relationships are implemented through CKRecord.Reference — essentially a foreign key pointing to another record's ID.
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.
// 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
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
)
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)
}
}
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.
| 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 |
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
}
}
}
Configure these in CloudKit Dashboard → Schema → Record Types → [PublishedEntry] → Security:
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.
| 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) |
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)")
}
}
}
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
}
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.
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
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")
}
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.
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
┌─────────────────────────────────────────────┐
│ 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 │
└─────────────────┘
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)
}
}
// 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
}
}
}
// 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
}
}
}
// 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()
}
}
// 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)
}
}
}
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.
String, Int64, Double, Data, Date, [String], [Int64], [Double], [Date], [Data]CKAsset and CKRecord.Reference cannot be encrypted// 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
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
}
}
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.
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
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
)
}
}
// 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/>
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) {}
}
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.
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.
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)
}
}
In the handleSentRecordZoneChanges method, when we get .serverRecordChanged:
failedSave.error.serverRecordlastKnownRecord to the server version (this resets the changeTag)nextRecordZoneChangeBatch again with our merged dataThis is exactly what our implementation does in Section 17.
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.
┌─────────────────────────────────────┐
│ SwiftUI Views │ ← Reads from SwiftData
├─────────────────────────────────────┤
│ @Observable ViewModel / Store │ ← Writes to SwiftData + queues sync
├─────────────────────────────────────┤
│ SwiftData (Local) │ ← Offline persistence
├─────────────────────────────────────┤
│ CKSyncEngine (Background) │ ← Syncs SwiftData ↔ CloudKit
├─────────────────────────────────────┤
│ CloudKit (Remote) │
└─────────────────────────────────────┘
// 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
}
}
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)
}
}
CloudKit errors are delivered as CKError instances. Proper error handling is critical — CloudKit is a networked service and will fail regularly.
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."
}
}
}
/// 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()
// }
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
}
}
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 queries — CKFetchRecordZoneChangesOperation 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
/\
/ \ 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
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 {}
// 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)
}
}
// 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 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")
}
}
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.
// 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])
}
}
}
| 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.
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
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.
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
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.
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
| 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 |
| 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) |
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.
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.