From sqlitedata-swift
Use when implementing CloudKit sync with SQLiteData — covers SyncEngine setup, sharing records, SyncMetadata queries, backwards-compatible migrations, schema constraints, account changes, and testing sync. NOT for core @Table/@FetchAll patterns (use core) or error lookup (use diag)
How this skill is triggered — by the user, by Claude, or both
Slash command
/sqlitedata-swift:sqd-cloudkitThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Complete guide for CloudKit sync with SQLiteData's `SyncEngine`.
Complete guide for CloudKit sync with SQLiteData's SyncEngine.
SyncEngine wraps Apple's CKSyncEngine (iOS 17+) and:
sqlitedata_icloud_metadata.sqlite) for CloudKit metadataBefore any code:
/skill sqd-cloudkit-setup Step 1)/skill sqd-cloudkit-setup Step 2)CKSharingSupported = true to Info.plist (if sharing — see /skill sqd-sharing)/skill sqd-cloudkit-setup Step 3)@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)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,
...
)
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
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?
}
ON DELETE actions: CASCADE, SET NULL, SET DEFAULTRESTRICT, NO ACTION (throws error)-- 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
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
New tables are safe. Unrecognized records from newer devices are cached until the table exists.
-- 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")
CloudKit schemas are additive-only once deployed to production (see /skill sqd-cloudkit-setup Step 3):
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).
@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
}
@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.
let serverRecord = try database.read { db in
try SyncMetadata
.find(remindersList.syncMetadataID)
.select(\.lastKnownServerRecord)
.fetchOne(db) ?? nil
}
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.
@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
try await syncEngine.unshare(record: remindersList)
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) }
}
}
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
}
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()
}
// 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()
@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.
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.
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?
}
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.
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() }
Simulators don't receive push notifications (the remote-notification background mode — see /skill sqd-cloudkit-setup Step 2), so:
syncEngine.syncChanges()/skill sqd-sharing)/skill sqd-cloudkit-setup Step 3)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)npx claudepluginhub sitapix/sqlitedata-swift-skills --plugin sqlitedata-swiftSearches MemPalace before answering questions about past work, people, projects, or prior decisions. Returns verbatim stored content instead of guessing from model memory.
Guides Payload CMS config (payload.config.ts), collections, fields, hooks, access control, APIs. Debugs validation errors, security, relationships, queries, transactions, hook behavior.
Implements vector databases with Pinecone, Weaviate, Qdrant, Milvus, pgvector for semantic search, RAG, recommendations, and similarity systems. Optimizes embeddings, indexing, and hybrid search.