# CloudKit Patterns

Lower-level operations and features behind the [skill overview](../SKILL.md):
incremental fetching, tokens, sharing, zones, files, batching, scheduling,
encryption and the Dashboard.

## Contents

- [Incremental zone fetches](#incremental-zone-fetches)
- [Server change tokens](#server-change-tokens)
- [Sharing with `CKShare`](#sharing-with-ckshare)
- [`UICloudSharingController`](#uicloudsharingcontroller)
- [Zones](#zones)
- [Files with `CKAsset`](#files-with-ckasset)
- [Batches](#batches)
- [Quality of service and groups](#quality-of-service-and-groups)
- [Encrypted fields](#encrypted-fields)
- [CloudKit Dashboard](#cloudkit-dashboard)
- [Discovering changed zones](#discovering-changed-zones)

## Incremental zone fetches

`CKFetchRecordZoneChangesOperation` returns only what changed since the last
token, per zone, in the private or shared database. Pass `nil` as the token for
the first full fetch.

```swift
import CloudKit

func pullZone(_ zone: CKRecordZone.ID, from db: CKDatabase, tokens: TokenStore) {
    let settings = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
    settings.previousServerChangeToken = tokens.token(for: zone)

    let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [zone],
                                               configurationsByRecordZoneID: [zone: settings])
    op.fetchAllChanges = true

    op.recordWasChangedBlock = { id, result in
        if case .success(let record) = result { Inbox.upsert(record) }
    }
    op.recordWithIDWasDeletedBlock = { id, recordType in
        Inbox.delete(id, type: recordType)
    }
    op.recordZoneFetchResultBlock = { zoneID, result in
        switch result {
        case .success(let outcome):
            tokens.set(outcome.serverChangeToken, for: zoneID)       // cache right away
        case .failure(let error as CKError) where error.code == .changeTokenExpired:
            tokens.set(nil, for: zoneID)                              // start over next time
        case .failure:
            break
        }
    }
    op.fetchRecordZoneChangesResultBlock = { overall in
        if case .failure(let error) = overall { SyncLog.record(error) }
    }
    op.qualityOfService = .utility
    db.add(op)
}
```

### Writing tokens to disk

```swift
final class TokenStore: @unchecked Sendable {
    private let file: URL
    private var tokens: [CKRecordZone.ID: CKServerChangeToken] = [:]
    private let lock = NSLock()

    init(file: URL) {
        self.file = file
        if let archived = try? Data(contentsOf: file),
           let dict = try? NSKeyedUnarchiver.unarchivedObject(
               ofClasses: [NSDictionary.self, CKRecordZone.ID.self, CKServerChangeToken.self],
               from: archived) as? [CKRecordZone.ID: CKServerChangeToken] {
            tokens = dict
        }
    }

    func token(for zone: CKRecordZone.ID) -> CKServerChangeToken? {
        lock.withLock { tokens[zone] }
    }

    func set(_ token: CKServerChangeToken?, for zone: CKRecordZone.ID) {
        lock.withLock {
            tokens[zone] = token
            let snapshot = tokens as NSDictionary
            if let encoded = try? NSKeyedArchiver.archivedData(withRootObject: snapshot, requiringSecureCoding: true) {
                try? encoded.write(to: file, options: .atomic)
            }
        }
    }
}
```

## Server change tokens

- A token is an opaque bookmark into one zone's (or one database's) change history.
- Tokens adopt `NSSecureCoding`, so they can be archived and stored.
- A zone token and a database token are different things; never swap them.
- `.changeTokenExpired` means the bookmark is too old. Set it to `nil` and refetch everything.
- Save the new token as soon as `recordZoneFetchResultBlock` delivers it.

For a handful of zones, `UserDefaults` works too:

```swift
func remember(_ token: CKServerChangeToken?, zone: String) {
    let key = "ck.token.\(zone)"
    guard let token,
          let blob = try? NSKeyedArchiver.archivedData(withRootObject: token as NSSecureCoding, requiringSecureCoding: true)
    else {
        UserDefaults.standard.removeObject(forKey: key)
        return
    }
    UserDefaults.standard.set(blob, forKey: key)
}
```

## Sharing with `CKShare`

`CKShare` (iOS 10+) shares one record hierarchy or an entire zone, with up to
100 participants per share.

```swift
func shareGarden(_ root: CKRecord, in db: CKDatabase) async throws -> URL? {
    let share = CKShare(rootRecord: root)       // children follow via their `parent` reference
    share[CKShare.SystemFieldKey.title] = "Community Garden" as CKRecordValue
    share[CKShare.SystemFieldKey.shareType] = "app.gardenlog.garden" as CKRecordValue
    share.publicPermission = .readOnly

    return try await withCheckedThrowingContinuation { (done: CheckedContinuation<URL?, Error>) in
        let op = CKModifyRecordsOperation(recordsToSave: [root, share], recordIDsToDelete: nil)
        op.modifyRecordsResultBlock = { result in
            switch result {
            case .success: done.resume(returning: share.url)
            case .failure(let problem): done.resume(throwing: problem)
            }
        }
        db.add(op)
    }
}
```

Save the root and the share **in the same operation**; `share.url` is set only
after that save succeeds. To share a whole zone, use
`CKShare(recordZoneID:)` instead.

### Adding people

```swift
func invite(_ emails: [String], to share: CKShare, container: CKContainer) async throws {
    let people = try await container.shareParticipants(
        for: emails.map { CKUserIdentity.LookupInfo(emailAddress: $0) })
    for (_, result) in people {
        let person = try result.get()
        person.permission = .readWrite
        share.addParticipant(person)
    }
    _ = try await container.privateCloudDatabase.save(share)
}
```

`container.shareParticipants(forEmailAddresses:)` is the shorter variant when
you only have addresses.

### Accepting a share

Share links open the app only when Info.plist sets `CKSharingSupported` to `YES`.

```swift
func windowScene(_ scene: UIWindowScene, userDidAcceptCloudKitShareWith metadata: CKShare.Metadata) {
    Task {
        let owner = CKContainer(identifier: metadata.containerIdentifier)
        _ = try await owner.accept([metadata])
        try await GardenSync.shared.pullNow()     // records now appear in sharedCloudDatabase
    }
}
```

The app delegate variant is `userDidAcceptCloudKitShare(with:)`.

## `UICloudSharingController`

The system sharing sheet, iOS only:

```swift
import UIKit
import CloudKit

final class GardenDetailViewController: UIViewController, UICloudSharingControllerDelegate {
    func presentSharing(_ share: CKShare, in container: CKContainer) {
        let sheet = UICloudSharingController(share: share, container: container)
        sheet.availablePermissions = [.allowReadOnly, .allowReadWrite, .allowPrivate]
        sheet.delegate = self
        present(sheet, animated: true)
    }

    func cloudSharingController(_ controller: UICloudSharingController, failedToSaveShareWithError saveError: Error) {
        SyncLog.record(saveError)
    }

    func itemTitle(for controller: UICloudSharingController) -> String? { "Community Garden" }
}
```

## Zones

Only zones you create yourself in the private store give you atomic commits,
token-based change tracking and sharing. The default zone has neither custom
change tokens nor atomic operations, so sync code should always use custom zones.

```swift
let zoneID = CKRecordZone.ID(zoneName: "Harvests", ownerName: CKCurrentUserDefaultName)
_ = try await privateDB.save(CKRecordZone(zoneID: zoneID))
let everyZone = try await privateDB.allRecordZones()        // list
_ = try await privateDB.deleteRecordZone(withID: zoneID)   // remove
```

## Files with `CKAsset`

Anything bigger than a few kilobytes (photos, audio, blobs) belongs in a
`CKAsset`; asset bytes are stored apart from the record and are exempt from its 1 MB cap.

```swift
func attachPhoto(_ jpeg: Data, to record: CKRecord) throws {
    let tmp = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString + ".jpg")
    try jpeg.write(to: tmp)
    record["photo"] = CKAsset(fileURL: tmp)
}

func keepPhoto(from record: CKRecord, into folder: URL) throws -> URL? {
    guard let asset = record["photo"] as? CKAsset, let staged = asset.fileURL else { return nil }
    let destination = folder.appending(path: record.recordID.recordName + ".jpg")
    try FileManager.default.moveItem(at: staged, to: destination)   // staging copy is temporary
    return destination
}
```

To delete an asset, set the field to `nil` and save; CloudKit cleans up
orphaned asset files on its own schedule. Use `desiredKeys` on fetch
operations to skip asset fields you do not need.

## Batches

`CKModifyRecordsOperation` saves and deletes in bulk:

```swift
let op = CKModifyRecordsOperation(recordsToSave: changed, recordIDsToDelete: removed)
op.savePolicy = .changedKeys        // upload only modified fields
op.isAtomic = true                  // every change lands or none does (custom zones)
op.perRecordSaveBlock = { id, result in Outbox.note(id, result) }
op.perRecordDeleteBlock = { id, result in Outbox.noteDeletion(id, result) }
op.modifyRecordsResultBlock = { result in Outbox.finish(result) }
privateDB.add(op)
```

One operation takes at most 400 records. Split larger sets into chunks.

## Quality of service and groups

| Work | QoS |
|---|---|
| The user is waiting on it | `.userInitiated` |
| Routine background sync | `.utility` |
| Prefetching, cleanup | `.background` |

```swift
func tagAsBackgroundSync(_ op: CKOperation) {
    let group = CKOperationGroup()
    group.expectedSendSize = .kilobytes
    group.expectedReceiveSize = .megabytes

    let configuration = CKOperation.Configuration()
    configuration.qualityOfService = .utility
    op.configuration = configuration
    op.group = group                  // the group is set on the operation itself
}
```

## Encrypted fields

`encryptedValues` (iOS 15+) encrypts sensitive fields in private and shared
databases, as long as you never need to query or sort on them.

- Only a **new** schema field can be encrypted; an existing field cannot be converted.
- Public database records cannot use encrypted values.
- `CKAsset` data is encrypted already.
- `CKRecord.Reference` fields stay unencrypted because the server needs them for relationships.

```swift
let journal = CKRecord(recordType: "Journal")
journal["title"] = "Tomato notes" as CKRecordValue               // plain, can be queried
journal.encryptedValues["body"] = "Blight on bed 3" as NSString   // encrypted
let body = fetched.encryptedValues["body"] as? String
```

## CloudKit Dashboard

Open it at `icloud.developer.apple.com` (the `/dashboard` path).

- **Schema**: record types, fields and indexes. In production, add indexes only for fields you actually query.
- **Records**: browse and edit data.
- **Subscriptions**: see what is registered.
- **Logs**: API calls, errors, latency.
- **Telemetry**: how many requests, how often they fail, and latency percentiles.
- **Environment switch**: Development or Production. The Simulator only talks to Development.
- **Deploy to Production** copies the schema. Production refuses to create record types or fields from code.

## Discovering changed zones

`CKFetchDatabaseChangesOperation` reports the zones whose contents moved since a database token.
It is how you find zones in the shared database when you do not know their IDs.

```swift
func changedZones(in db: CKDatabase, since token: CKServerChangeToken?,
                  store: @escaping @Sendable (CKServerChangeToken?) -> Void,
                  then fetchZones: @escaping @Sendable ([CKRecordZone.ID]) -> Void) {
    let op = CKFetchDatabaseChangesOperation(previousServerChangeToken: token)
    op.fetchAllChanges = true
    let collected = ZoneCollector()
    op.recordZoneWithIDChangedBlock = { collected.add($0) }
    op.recordZoneWithIDWasDeletedBlock = { LocalStore.dropZone($0) }
    op.fetchDatabaseChangesResultBlock = { result in
        switch result {
        case .success(let outcome):
            store(outcome.serverChangeToken)
            fetchZones(collected.all)          // then run the zone-changes operation
        case .failure(let error as CKError) where error.code == .changeTokenExpired:
            store(nil)
        case .failure:
            break
        }
    }
    db.add(op)
}
```
