mcpField
A
axiom-cloud-sync

axiom-cloud-sync

github.com/charleswiltgenクラウド・DevOps1.2k184 回インストール今日更新
リポジトリ

axiom-cloud-sync について

iCloud 同期を設計する Apple プラットフォーム開発者向けのスキルです。構造化データとファイルに応じた CloudKit または iCloud Drive の選択、オフライン優先設計、競合処理を扱います。

主な機能

  1. 01CloudKit と iCloud Drive の選択
  2. 02オフライン優先の同期パターン
  3. 03競合解決の方針
  4. 04SwiftData と CKSyncEngine の構成
  5. 05大規模データのバッチ同期指針

活用シーン

  1. 01レコードや文書に適した同期方式の選定
  2. 02オフラインでも使える同期フローの設計
  3. 03複数デバイスでの編集競合への対応
  4. 04大量レコードの初回同期の計画

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-cloud-sync

インストール後、Claude Code で/axiom-cloud-syncを入力して読み込まれたか確認します。

詳細

クラウド同期

概要

基本原則:データの形状に適した同期技術を選び、ネットワーク障害に適切に対処するオフラインファーストのパターンを実装します。

同期方法には、大きく異なる次の 2 つがあります。

  • CloudKit — 構造化データ(フィールドやリレーションシップを持つレコード)
  • iCloud Drive — ファイルベースのデータ(書類、画像、あらゆるファイル形式)

クイック判定ツリー

What needs syncing?

├─ Structured data (records, relationships)?
│  ├─ Using SwiftData? → SwiftData + CloudKit (easiest, iOS 17+)
│  ├─ Need shared/public database? → CKSyncEngine or raw CloudKit
│  └─ Custom persistence (GRDB, SQLite)? → CKSyncEngine (iOS 17+)
│
├─ Documents/files users expect in Files app?
│  └─ iCloud Drive (UIDocument or FileManager)
│
├─ Large binary blobs (images, videos)?
│  ├─ Associated with structured data? → CKAsset in CloudKit
│  └─ Standalone files? → iCloud Drive
│
└─ App settings/preferences?
   └─ NSUbiquitousKeyValueStore (simple key-value, 1MB limit)

CloudKit と iCloud Drive の比較

項目CloudKitiCloud Drive
データの形状構造化レコードファイル/書類
クエリのサポート完全なクエリ言語ファイル名のみ
リレーションシップネイティブでサポートなし(手動)
競合解決レコード単位ファイル単位
ユーザーからの見え方ユーザーには非表示ファイルアプリに表示
共有レコード/データベースの共有ファイル共有
オフラインローカルキャッシュが必要自動ダウンロード

注意すべき兆候

以下のいずれかが当てはまる場合は、いったん停止して再検討してください。

  • ❌ 「JSON ファイルを CloudKit に保存する」 — 適切なツールではありません。ファイルには iCloud Drive を使ってください
  • ❌ 「iCloud Drive でリレーションシップを手動で構築する」 — 適切なツールではありません。CloudKit を使ってください
  • ❌ 「同期は瞬時に完了すると想定する」 — ネットワークは切断されます。オフラインファーストで設計してください
  • ❌ 「競合処理を省略する」 — 複数のデバイスを使うと、競合は必ず発生します
  • ❌ 「ユーザーの書類に CloudKit を使う」 — ユーザーには表示されません。iCloud Drive を使ってください
  • ❌ 「アプリの起動時だけ同期する」 — ユーザーは継続的な同期を期待しています

オフラインファーストのパターン

必須:すべての同期コードは、まずオフラインで動作する必要があります。

// ✅ CORRECT: Offline-first architecture
class OfflineFirstSync {
    private let localStore: LocalDatabase  // GRDB, SwiftData, Core Data
    private let syncEngine: CKSyncEngine

    // Write to LOCAL first, sync to cloud in background
    func save(_ item: Item) async throws {
        // 1. Save locally (instant)
        try await localStore.save(item)

        // 2. Queue for sync (non-blocking)
        syncEngine.state.add(pendingRecordZoneChanges: [
            .saveRecord(item.recordID)
        ])
    }

    // Read from LOCAL (instant)
    func fetch() async throws -> [Item] {
        return try await localStore.fetchAll()
    }
}

// ❌ WRONG: Cloud-first (blocks on network)
func save(_ item: Item) async throws {
    // Fails when offline, slow on bad network
    try await cloudKit.save(item)
    try await localStore.save(item)
}

競合解決の戦略

同期前に 2 台のデバイスで同じデータを編集すると、競合が発生します。

戦略 1:最終書き込み優先(最もシンプル)
// Server always has latest, client accepts it
func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
    return server  // Accept server version
}

使用する場面:データが重要ではなく、上書きされてもユーザーが気付かない場合

戦略 2:マージ(最も一般的)
// Combine changes from both versions
func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
    let merged = server.copy() as! CKRecord

    // For each field, apply custom merge logic
    merged["notes"] = mergeText(
        local["notes"] as? String,
        server["notes"] as? String
    )
    merged["tags"] = mergeSets(
        local["tags"] as? [String] ?? [],
        server["tags"] as? [String] ?? []
    )

    return merged
}

使用する場面:両方のバージョンに価値のある変更が含まれている場合

戦略 3:ユーザーが選択
// Present conflict to user
func resolveConflict(local: CKRecord, server: CKRecord) async -> CKRecord {
    let choice = await presentConflictUI(local: local, server: server)
    return choice == .keepLocal ? local : server
}

使用する場面:データが重要で、ユーザー自身の判断が必要な場合

一般的なパターン

パターン 1:SwiftData + CloudKit(新規アプリに推奨)
import SwiftData

// Automatic CloudKit sync with zero configuration
@Model
class Note {
    var title: String
    var content: String
    var createdAt: Date

    init(title: String, content: String) {
        self.title = title
        self.content = content
        self.createdAt = Date()
    }
}

// Container automatically syncs if CloudKit entitlement present
let container = try ModelContainer(for: Note.self)

制限事項:

  • プライベートデータベースのみ(パブリック/共有データベースは不可)
  • 自動同期(タイミングの制御は限定的)
  • カスタムの競合解決は不可
  • CloudKit 同期では @Attribute(.unique) がサポートされません。CloudKit を使用する場合は削除してください
パターン 2:CKSyncEngine(カスタム永続化)
// For GRDB, SQLite, or custom databases
class MySyncManager: CKSyncEngineDelegate {
    private let engine: CKSyncEngine
    private let database: GRDBDatabase

    func handleEvent(_ event: CKSyncEngine.Event) async {
        switch event {
        case .stateUpdate(let update):
            // Persist sync state
            await saveSyncState(update.stateSerialization)

        case .fetchedDatabaseChanges(let changes):
            // Apply changes to local DB
            for zone in changes.modifications {
                await handleZoneChanges(zone)
            }

        case .sentRecordZoneChanges(let sent):
            // Mark records as synced
            for saved in sent.savedRecords {
                await markSynced(saved.recordID)
            }
        }
    }
}

CKSyncEngine のセットアップ全体については、axiom-cloudkit-ref を参照してください。

パターン 3:iCloud Drive の書類
import UIKit

class MyDocument: UIDocument {
    var content: Data?

    override func contents(forType typeName: String) throws -> Any {
        return content ?? Data()
    }

    override func load(fromContents contents: Any, ofType typeName: String?) throws {
        content = contents as? Data
    }
}

// Save to iCloud Drive (visible in Files app)
let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)?
    .appendingPathComponent("Documents")
    .appendingPathComponent("MyFile.txt")

let doc = MyDocument(fileURL: url!)
doc.content = "Hello".data(using: .utf8)
doc.save(to: url!, for: .forCreating)

NSFileCoordinator と競合処理については、axiom-icloud-drive-ref を参照してください。

アンチパターン

1. 同期状態を無視する
// ❌ WRONG: No awareness of pending changes
var items: [Item] = []  // Are these synced? Pending? Conflicted?

// ✅ CORRECT: Track sync state
struct SyncableItem {
    let item: Item
    let syncState: SyncState  // .synced, .pending, .conflict
}
2. 同期中に UI をブロックする
// ❌ WRONG: UI blocks until sync completes
func viewDidLoad() async {
    items = try await cloudKit.fetchAll()  // Spinner forever on airplane
    tableView.reloadData()
}

// ✅ CORRECT: Show local data immediately
func viewDidLoad() {
    items = localStore.fetchAll()  // Instant
    tableView.reloadData()

    Task {
        await syncEngine.fetchChanges()  // Background update
    }
}
3. CloudKit のスキーマを本番環境にデプロイしていない

CloudKit には、開発環境と本番環境で別々のスキーマがあります。App Store 上のアプリからアクセスできるのは、本番環境のみです。開発環境でレコードタイプ、フィールド、インデックスを追加しても、デプロイしなければ、本番環境でのクエリはエラーなしで空の結果を返します。

❌ Works in Xcode/TestFlight (Development) → empty results in App Store (Production)
   Queries silently return zero results — no CKError, no crash, no clue.

✅ Before every App Store submission:
   1. CloudKit Console → Select container
   2. "Deploy Schema Changes" → Review changes → Deploy
   3. Test with Production environment in Xcode scheme settings

省略した場合の時間コスト:3~7 日(審査のやり直し+「TestFlight では動くのに、本番環境では動かないのはなぜか?」のデバッグ)。これは初めて提出する人が遭遇する CloudKit の落とし穴の中で、最もよくあるものです。

4. 再試行ロジックがない
// ❌ WRONG: Single attempt
try await cloudKit.save(record)

// ✅ CORRECT: Exponential backoff
func saveWithRetry(_ record: CKRecord, attempts: Int = 3) async throws {
    for attempt in 0..<attempts {
        do {
            try await cloudKit.save(record)
            return
        } catch let error as CKError where error.isRetryable {
            let delay = pow(2.0, Double(attempt))
            try await Task.sleep(for: .seconds(delay))
        }
    }
    throw SyncError.maxRetriesExceeded
}

同期状態の表示

同期状態は、常にユーザーに表示してください。

enum SyncState {
    case synced       // ✓ (checkmark)
    case pending      // ↻ (arrows)
    case conflict     // ⚠ (warning)
    case offline      // ☁ with X
}

// In SwiftUI
HStack {
    Text(item.title)
    Spacer()
    SyncIndicator(state: item.syncState)
}

エンタイトルメントのチェックリスト

同期を機能させるには、次の項目を確認してください。

  1. Xcode → Signing & Capabilities

    • ✓ iCloud 機能を追加済み
    • ✓ CloudKit にチェック(CloudKit の場合)
    • ✓ iCloud Documents にチェック(iCloud Drive の場合)
    • ✓ コンテナを選択/作成済み
  2. Apple Developer Portal

    • ✓ App ID に iCloud 機能を設定済み
    • ✓ CloudKit コンテナが存在する(CloudKit の場合)
  3. CloudKit Console(App Store 提出前)

    • ✓ スキーマを本番環境にデプロイ済み(レコードタイプ、フィールド、インデックス)
    • ✓ Xcode のスキームで本番環境を指定してテストし、クエリが動作することを確認済み
  4. デバイス

    • ✓ iCloud にサインイン済み
    • ✓ iCloud Drive を有効化済み(設定 → [名前]→ iCloud)

大規模データセットの同期

10,000 件以上のレコードを同期する場合、単純な方法ではタイムアウトや起動の遅延が発生します。

初回同期の戦略
// ❌ WRONG: Fetch everything at once
let allRecords = try await database.fetchAll()
syncEngine.state.add(pendingRecordZoneChanges: allRecords.map { .saveRecord($0.recordID) })

// ✅ CORRECT: Batch initial sync
func performInitialSync(batchSize: Int = 200) async throws {
    var cursor: CKQueryOperation.Cursor? = nil

    repeat {
        let (results, nextCursor) = try await database.records(
            matching: query,
            resultsLimit: batchSize,
            desiredKeys: nil,
            continuationCursor: cursor
        )
        // Process batch
        try await localStore.saveBatch(results.compactMap { try? $0.1.get() })
        cursor = nextCursor
    } while cursor != nil
}
差分同期(初回以降)

CKSyncEngine は差分同期を自動的に処理します。最後の同期トークン以降の変更のみを取得します。次回起動時にエンジンがすべてを再取得しないよう、stateSerialization を永続化してください。

パフォーマンスのガイドライン
データセットのサイズ戦略備考
1,000 件未満デフォルトの CKSyncEngineそのまま使えます
1,000~10,000 件初回同期をバッチ処理200 件単位のバッチで処理し、進行状況を表示する UI を表示します
10,000 件以上ページネーション+バックグラウンド処理初回同期には BGProcessingTask を使用します
100,000 件以上サーバー側でフィルタリングユーザーに必要なデータのみ同期し、残りは必要に応じて読み込みます

重要なポイント:ボトルネックとなるのは初回同期です。初回同期後は、差分のみを取得する CKSyncEngine の差分同期により、大規模なデータセットも効率的に処理できます。

想定されるプレッシャーへの対応

シナリオ 1:「v1 では競合処理を省略しよう」

状況:競合解決なしでリリースしようという、締め切りへのプレッシャー。

リスク:ユーザーは複数のデバイスで必ず編集します。データは気づかれないまま失われます。

対応:「最低限の競合処理にかかる時間は 2 時間です。気づかないデータ損失はユーザーを失い、星 1 つのレビューにつながります。」

シナリオ 2:「アプリ起動時に同期すれば十分」

状況:継続的な同期の複雑さを避けようとしています。

リスク:ユーザーは、次回の起動時ではなく、数秒以内に変更が反映されることを期待します。

対応:継続的な同期を自動的に処理する CKSyncEngine または SwiftData を使用してください。

関連スキル

  • axiom-cloudkit-ref — CloudKit API の完全なリファレンス
  • axiom-icloud-drive-ref — NSFileCoordinator を使ったファイルベースの同期
  • axiom-cloud-sync-diag — 同期失敗のデバッグ
  • axiom-storage — データをローカルのどこに保存するかの選択

コメント