SQLiteData CloudKit Synchronization
Complete guide for CloudKit sync with SQLiteData's SyncEngine.
Architecture
SyncEngine wraps Apple's CKSyncEngine (iOS 17+) and:
- Monitors local SQLite changes via database triggers
- Sends local changes to CloudKit (private + shared databases)
- Receives remote changes and applies them locally
- Manages a separate metadatabase (
sqlitedata_icloud_metadata.sqlite) for CloudKit metadata - Handles field-level conflict resolution ("last edit wins" per column)
1. Project Setup (Xcode)
Before any code:
- Enable iCloud capability → check CloudKit (see
/skill sqd-cloudkit-setupStep 1) - Enable Background Modes → check Remote notifications (see
/skill sqd-cloudkit-setupStep 2) - Add
CKSharingSupported = trueto Info.plist (if sharing — see/skill sqd-sharing) - Before shipping: deploy schema to production (see
/skill sqd-cloudkit-setupStep 3)
2. SyncEngine Initialization
@main
struct MyApp: App {
@State var syncDelegate = MySyncDelegate()
init() {
try! prepareDependencies {
$0.defaultDatabase = try appDatabase()
$0.defaultSyncEngine = try SyncEngine(
for: $0.defaultDatabase,
tables: RemindersList.self, Reminder.self, Tag.self, ReminderTag.self,
privateTables: UserPreferences.self, // Not shareable
containerIdentifier: "iCloud.com.example.app", // nil = from entitlements
startImmediately: true, // default
delegate: syncDelegate,
logger: Logger(subsystem: "MyApp", category: "CloudKit")
)
}
}
}
Key distinction:
tables:— Synced AND shareable with other iCloud usersprivateTables:— Synced but NOT shareable (private database only)
3. Schema Requirements for CloudKit
Globally Unique Primary Keys (REQUIRED)
Your @Table UUID primary key becomes a CKRecord.ID record name in CloudKit — ASCII, max 255 chars, unique per zone (see /skill sqd-sharing for the underlying CloudKit constraints).
-- CORRECT: UUID primary key with ON CONFLICT REPLACE
CREATE TABLE "items" (
"id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()),
...
) STRICT
-- WRONG: Integer autoincrement (conflict across devices)
CREATE TABLE "items" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
...
)
Primary Key on EVERY Synced Table (REQUIRED)
Even join tables need a single (non-compound) primary key:
CREATE TABLE "reminderTags" (
"id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()),
"reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE,
"tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE
) STRICT
NO Uniqueness Constraints (REQUIRED)
Tables with UNIQUE constraints (other than primary key) cannot be synchronized. SyncEngine throws an error on initialization if detected.
Workaround: Make the unique column the primary key itself:
@Table
struct RemindersListAsset {
@Column(primaryKey: true)
let remindersListID: RemindersList.ID // Acts as both PK and FK
var coverImage: Data?
}
Foreign Key Rules
- Supported
ON DELETEactions:CASCADE,SET NULL,SET DEFAULT - NOT supported:
RESTRICT,NO ACTION(throws error) - SyncEngine handles out-of-order records (caches children until parent arrives)
NOT NULL Columns MUST Have ON CONFLICT REPLACE
-- CORRECT
"position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0
-- WRONG (will fail when older devices sync records without this column)
"position" INTEGER NOT NULL DEFAULT 0
Reserved CloudKit Field Names (DO NOT USE)
These are CKRecord system metadata fields (see /skill sqd-sharing for full list). Do not use as column names:
creationDate, creatorUserRecordID, etag, lastModifiedUserRecordID, modificationDate, modifiedByDevice, recordChangeTag, recordID, recordType
4. Backwards-Compatible Migrations
Adding Tables — Safe
New tables are safe. Unrecognized records from newer devices are cached until the table exists.
Adding Columns — Use ON CONFLICT REPLACE or nullable
-- Option A: NOT NULL with ON CONFLICT REPLACE + default
ALTER TABLE "remindersLists"
ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0
-- Option B: Nullable (when no sensible default exists)
ALTER TABLE "remindersLists"
ADD COLUMN "groupID" TEXT REFERENCES "groups"("id")
DISALLOWED Migrations
CloudKit schemas are additive-only once deployed to production (see /skill sqd-cloudkit-setup Step 3):
- Removing columns
- Renaming columns
- Renaming tables
5. Attaching Metadatabase
To query SyncMetadata (CloudKit record data), attach in prepareDatabase:
configuration.prepareDatabase { db in
try db.attachMetadatabase()
}
This enables joining SyncMetadata to your tables to access CKRecord metadata and CKShare data (see /skill sqd-sharing).
6. SyncMetadata Table
@Table("sqlitedata_icloud_metadata")
public struct SyncMetadata: Hashable, Identifiable, Sendable {
public struct ID: Hashable, Sendable {
public let recordPrimaryKey: String
public let recordType: String
}
public let id: ID
public var recordPrimaryKey: String { id.recordPrimaryKey }
public var recordType: String { id.recordType }
public let zoneName: String
public let ownerName: String
public let recordName: String
public let parentRecordID: ParentID?
public let lastKnownServerRecord: CKRecord?
public let share: CKShare?
public var hasLastKnownServerRecord: Bool
public var isShared: Bool
public let userModificationTime: Int64
}
Joining SyncMetadata to Your Tables
@FetchAll(
RemindersList
.leftJoin(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) }
.select {
Row.Columns(
remindersList: $0,
isShared: $1.isShared ?? false,
share: $2.share
)
}
)
var rows
Use $0.syncMetadataID — available on all PrimaryKeyedTable types — to join.
Fetching CKRecord for a Record
let serverRecord = try database.read { db in
try SyncMetadata
.find(remindersList.syncMetadataID)
.select(\.lastKnownServerRecord)
.fetchOne(db) ?? nil
}
7. Sharing Records
SQLiteData wraps CloudKit's sharing API (CKShare, UICloudSharingController). For Apple's underlying sharing model — record zones vs hierarchies, participant management, permission types, and UICloudSharingController sample code — see /skill sqd-sharing.
Share a Record
@Dependency(\.defaultSyncEngine) var syncEngine
let sharedRecord = try await syncEngine.share(
record: remindersList,
configure: { share in
share.publicPermission = .readOnly
// or .readWrite, .none
}
)
// sharedRecord.share is the CKShare
Unshare
try await syncEngine.unshare(record: remindersList)
Accept Incoming Share (SceneDelegate)
When a user taps a share URL, CloudKit provides CKShare.Metadata to your app delegate. SQLiteData simplifies acceptance — for the full CloudKit acceptance flow, see /skill sqd-sharing.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@Dependency(\.defaultSyncEngine) var syncEngine
func windowScene(
_ windowScene: UIWindowScene,
userDidAcceptCloudKitShareWith metadata: CKShare.Metadata
) {
Task { try await syncEngine.acceptShare(metadata: metadata) }
}
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let metadata = connectionOptions.cloudKitShareMetadata else { return }
Task { try await syncEngine.acceptShare(metadata: metadata) }
}
}
Write Permission Errors
do {
try await database.write { db in
try Reminder.find(id).update { $0.title = "New" }.execute(db)
}
} catch let error as DatabaseError where error.message == SyncEngine.writePermissionError {
// User doesn't have write permission on this shared record
}
8. SyncEngine State Observation
All observable in SwiftUI:
@Dependency(\.defaultSyncEngine) var syncEngine
syncEngine.isRunning // Bool
syncEngine.isSynchronizing // Bool (sending OR fetching)
syncEngine.isSendingChanges // Bool
syncEngine.isFetchingChanges // Bool
Usage in UI:
if syncEngine.isSynchronizing {
ProgressView()
}
9. SyncEngine Control
// Manual sync
try await syncEngine.start()
syncEngine.stop()
try await syncEngine.fetchChanges(options)
try await syncEngine.sendChanges(options)
try await syncEngine.syncChanges() // fetch + send
// Delete all local data (e.g., on account change)
try await syncEngine.deleteLocalData()
10. Account Change Handling (SyncEngineDelegate)
@MainActor
@Observable
class MySyncDelegate: SyncEngineDelegate {
var isDeleteLocalDataAlertPresented = false
func syncEngine(
_ syncEngine: SyncEngine,
accountChanged changeType: CKSyncEngine.Event.AccountChange.ChangeType
) async {
switch changeType {
case .signIn:
break
case .signOut, .switchAccounts:
isDeleteLocalDataAlertPresented = true
@unknown default:
break
}
}
}
// In view:
.alert("Reset local data?", isPresented: $delegate.isDeleteLocalDataAlertPresented) {
Button("Reset", role: .destructive) {
Task { try await syncEngine.deleteLocalData() }
}
}
Default behavior (no delegate): Auto-deletes local data on sign out.
11. Triggers + Sync Awareness
Skip trigger actions during sync using SyncEngine.isSynchronizing:
// StructuredQueries builder
Model.createTemporaryTrigger(
after: .insert { new in ... }
when: { _ in !SyncEngine.$isSynchronizing }
)
// Raw SQL
#sql("""
CREATE TEMPORARY TRIGGER "..."
AFTER DELETE ON "..."
FOR EACH ROW WHEN NOT \(SyncEngine.$isSynchronizing)
BEGIN ... END
""")
When to use: Triggers that set updatedAt timestamps or app-specific side effects.
When NOT to use: FTS index triggers should run regardless of sync source.
12. Assets (BLOB Columns)
BLOB columns are automatically converted to CKAssets. Best practice: separate table for large data:
@Table
struct RemindersListAsset {
@Column(primaryKey: true)
let remindersListID: RemindersList.ID
var coverImage: Data?
}
13. Primary Key Migration (Integer → UUID)
For existing apps with integer primary keys:
migrator.registerMigration("Migrate to UUID primary keys") { db in
try SyncEngine.migratePrimaryKeys(
db,
tables: Reminder.self, RemindersList.self, Tag.self
)
}
This handles: UUID generation (deterministic via MD5), data preservation, foreign key updates, index/trigger recreation.
14. Testing & Previews with SyncEngine
Bootstrap helper pattern:
extension DependencyValues {
mutating func bootstrapDatabase(
syncEngineDelegate: (any SyncEngineDelegate)? = nil
) throws {
defaultDatabase = try appDatabase()
defaultSyncEngine = try SyncEngine(
for: defaultDatabase,
tables: RemindersList.self, Reminder.self,
delegate: syncEngineDelegate
)
}
}
// App: try! prepareDependencies { try $0.bootstrapDatabase(syncEngineDelegate: delegate) }
// Test: @Suite(.dependencies { try! $0.bootstrapDatabase() })
// Preview: let _ = try! prepareDependencies { try $0.bootstrapDatabase() }
15. Conflict Resolution
- Strategy: field-wise last edit wins (per column, not per record)
- Each column edit is timestamped
- When merging conflicts, the most recently edited column value wins
- No CRDT support (may come in future)
16. Simulator Limitations
Simulators don't receive push notifications (the remote-notification background mode — see /skill sqd-cloudkit-setup Step 2), so:
- Changes don't auto-sync from CloudKit to simulator
- Force sync: kill and relaunch the app, or use
syncEngine.syncChanges()
Common CloudKit Mistakes
- Integer primary keys — Must be UUID TEXT for distributed sync (see
/skill sqd-sharing) - UNIQUE constraints on non-PK columns — Not allowed with sync
- NOT NULL without ON CONFLICT REPLACE — Breaks cross-version sync
- RESTRICT/NO ACTION foreign keys — Not supported
- Editing deployed migrations — Always add new migrations (see
/skill sqd-cloudkit-setupStep 3) - Removing/renaming columns — Not allowed with distributed schema
- Not attaching metadatabase — Required to query SyncMetadata
- Using reserved CloudKit field names as column names
Apple Documentation Skills
For Apple's CloudKit documentation (no web search needed):
/skill sqd-cloudkit-setup— iCloud capability, background modes, schema deployment/skill sqd-sharing— CKShare, CKRecord.ID, UICloudSharingController, permissions/skill sqd-swiftdata-sync— SwiftData sync (for comparison/migration)