agentsclimarketplace

Sqd cloudkit

Skill sitapix/sqlitedata-swift-skills/skills/sqd-cloudkit

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)From its SKILL.md

Install
npx -y skills add sitapix/sqlitedata-swift-skills --skill sqd-cloudkit

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

13.1 KB, ~3.2k tokens by cl100k_base, as published. Nobody here has run it

SQLiteData CloudKit Synchronization

Complete guide for CloudKit sync with SQLiteData's SyncEngine.

Architecture

SyncEngine wraps Apple's CKSyncEngine (iOS 17+) and:

  1. Monitors local SQLite changes via database triggers
  2. Sends local changes to CloudKit (private + shared databases)
  3. Receives remote changes and applies them locally
  4. Manages a separate metadatabase (sqlitedata_icloud_metadata.sqlite) for CloudKit metadata
  5. Handles field-level conflict resolution ("last edit wins" per column)

1. Project Setup (Xcode)

Before any code:

  1. Enable iCloud capability → check CloudKit (see /skill sqd-cloudkit-setup Step 1)
  2. Enable Background Modes → check Remote notifications (see /skill sqd-cloudkit-setup Step 2)
  3. Add CKSharingSupported = true to Info.plist (if sharing — see /skill sqd-sharing)
  4. Before shipping: deploy schema to production (see /skill sqd-cloudkit-setup Step 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 users
  • privateTables: — 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 DELETE actions: 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

  1. Integer primary keys — Must be UUID TEXT for distributed sync (see /skill sqd-sharing)
  2. UNIQUE constraints on non-PK columns — Not allowed with sync
  3. NOT NULL without ON CONFLICT REPLACE — Breaks cross-version sync
  4. RESTRICT/NO ACTION foreign keys — Not supported
  5. Editing deployed migrations — Always add new migrations (see /skill sqd-cloudkit-setup Step 3)
  6. Removing/renaming columns — Not allowed with distributed schema
  7. Not attaching metadatabase — Required to query SyncMetadata
  8. 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)

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.