mcpField
A
axiom-realm-migration-ref

axiom-realm-migration-ref

github.com/charleswiltgenデータ・データベース1.2k165 回インストール今日更新
リポジトリ

axiom-realm-migration-ref について

Realm から SwiftData へアプリのデータモデルと保存データを移す開発者向けのリファレンススキルです。モデルや並行処理の変換、移行方式、CloudKit への移行、データ検証を扱います。

主な機能

  1. 01Realm から SwiftData へのモデル変換例
  2. 02Swift Concurrency へのスレッド処理移行
  3. 03直接移行と変換レイヤー方式
  4. 04CloudKit 同期への移行ガイド
  5. 05データ整合性と移行の確認

活用シーン

  1. 01Realm アプリの SwiftData 移行計画
  2. 02Realm のリレーションとスレッド処理の変換
  3. 03インポートしたレコードとリレーションの検証

インストール

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

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-realm-migration-ref

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

詳細

Realm から SwiftData への移行 — リファレンスガイド

目的: Realm から SwiftData への完全な移行手順 Swift バージョン: Swift 5.9+(厳密な並行処理を推奨する Swift 6) iOS バージョン: iOS 17+(iOS 26+ を推奨) 背景: Realm Device Sync は 2025 年 9 月 30 日に終了します。このガイドは、期限前に移行する Realm ユーザーにとって必須です。


重要なスケジュール

Realm Device Sync の廃止期限 = 2025 年 9 月 30 日

アプリで Realm Sync を使用している場合:

  • ⚠️ 2025 年 9 月 30 日までに必ず移行してください
  • ✅ SwiftData が推奨される代替手段です
  • ⏰ 残り時間:現在の日付によりますが、本番アプリの移行には 2〜8 週間かかります

このガイドには、移行を成功させるために必要なすべてが記載されています。


移行戦略の概要

Phase 1 (Week 1-2): Preparation & Planning
├─ Audit current Realm usage
├─ Understand model relationships
├─ Plan data migration path
└─ Set up test environment

Phase 2 (Week 2-3): Development
├─ Create SwiftData models from Realm schemas
├─ Implement data migration logic
├─ Convert threading model to async/await
└─ Test with real data

Phase 3 (Week 3-4): Migration
├─ Migrate existing app users' data
├─ Run in parallel (Realm + SwiftData)
├─ Verify CloudKit sync works
└─ Monitor for issues

Phase 4 (Week 4+): Production
├─ Deploy update with parallel persistence
├─ Gradual cutover from Realm to SwiftData
├─ Deprecate Realm code
└─ Monitor CloudKit sync health

パート 1:パターンの対応関係

モデル定義の変換
Realm → SwiftData:基本モデル
// REALM
class RealmTrack: Object {
    @Persisted(primaryKey: true) var id: String
    @Persisted var title: String
    @Persisted var artist: String
    @Persisted var duration: TimeInterval
    @Persisted var genre: String?
}

// SWIFTDATA
@Model
final class Track {
    @Attribute(.unique) var id: String  // remove if using CloudKit sync
    var title: String
    var artist: String
    var duration: TimeInterval
    var genre: String?

    init(id: String, title: String, artist: String, duration: TimeInterval, genre: String? = nil) {
        self.id = id
        self.title = title
        self.artist = artist
        self.duration = duration
        self.genre = genre
    }
}

主な違い:

  • Realm:@Persisted(primaryKey: true) → SwiftData:@Attribute(.unique)(CloudKit 同期ではサポートされていません。CloudKit を使用する場合は削除してください)
  • Realm:暗黙的な初期化 → SwiftData:明示的な初期化が必要
  • Realm:Object 基底クラス → SwiftData:final class に付ける @Model マクロ
Realm → SwiftData:リレーションシップ
// REALM: One-to-Many
class RealmAlbum: Object {
    @Persisted(primaryKey: true) var id: String
    @Persisted var title: String
    @Persisted var tracks: RealmSwiftCollection<RealmTrack>
}

// SWIFTDATA: One-to-Many
@Model
final class Album {
    @Attribute(.unique) var id: String
    var title: String

    @Relationship(deleteRule: .cascade, inverse: \Track.album)
    var tracks: [Track] = []
}

@Model
final class Track {
    @Attribute(.unique) var id: String
    var title: String
    var album: Album?  // Inverse automatically maintained
}

主な違い:

  • Realm:明示的な RealmSwiftCollection 型 → SwiftData:ネイティブの [Track] 配列
  • Realm:手動でのリレーションシップ管理 → SwiftData:逆方向のリレーションシップを自動管理
  • Realm:削除ルールなし → SwiftData:deleteRule: .cascade / .nullify / .deny
Realm → SwiftData:インデックス
// REALM
class RealmTrack: Object {
    @Persisted(primaryKey: true) var id: String
    @Persisted(indexed: true) var genre: String
    @Persisted(indexed: true) var releaseDate: Date
}

// SWIFTDATA
@Model
final class Track {
    @Attribute(.unique) var id: String
    @Attribute(.indexed) var genre: String = ""
    @Attribute(.indexed) var releaseDate: Date = Date()
}

パート 2:スレッドモデルの変換

Realm のスレッド処理 → Swift の並行処理
Realm:手動のスレッド処理
class RealmDataManager {
    func fetchTracksOnBackground() {
        DispatchQueue.global().async {
            let realm = try! Realm()  // Must get Realm on each thread
            let tracks = realm.objects(RealmTrack.self)

            DispatchQueue.main.async {
                self.updateUI(tracks: Array(tracks))
            }
        }
    }

    func saveTrackOnBackground(_ track: RealmTrack) {
        DispatchQueue.global().async {
            let realm = try! Realm()
            try! realm.write {
                realm.add(track)
            }
        }
    }
}

問題点:

  • 手動の DispatchQueue によるスレッド処理はエラーが起きやすい
  • オブジェクトに誤ったスレッドからアクセスしやすい
  • コンパイル時の保証がない
SwiftData:アクターベースの並行処理
actor SwiftDataManager {
    let modelContainer: ModelContainer

    func fetchTracks() async -> [Track] {
        let context = ModelContext(modelContainer)
        let descriptor = FetchDescriptor<Track>()
        return (try? context.fetch(descriptor)) ?? []
    }

    func saveTrack(_ track: Track) async {
        let context = ModelContext(modelContainer)
        context.insert(track)
        try? context.save()
    }
}

// Usage (automatic thread handling)
@MainActor
class ViewController: UIViewController {
    @State private var tracks: [Track] = []
    private let manager: SwiftDataManager

    func loadTracks() async {
        tracks = await manager.fetchTracks()
    }
}

利点:

  • DispatchQueue の手動操作が不要
  • コンパイル時のスレッド安全性
  • アクターによる分離を自動化
  • Swift 6 の厳密な並行処理に対応
よく使われるスレッド処理パターン
Realm のパターンSwiftData のパターン
DispatchQueue.global().asyncバックグラウンドアクターでの async/await
realm.write { }context.insert() + context.save()
手動で管理するスレッドローカルの Realm インスタンス共有の ModelContainer + バックグラウンドの ModelContext
Thread.isMainThread のチェック@MainActor アノテーション

パート 3:スキーマ移行戦略

シンプルなスキーマ移行(直接変換)

シンプルなスキーマ(テーブル 5 個未満、フィールド 10 個未満)のアプリでは、直接移行は簡単です。

actor SchemaImporter {
    let realmPath: String
    let modelContainer: ModelContainer

    func migrateFromRealm() async throws {
        // 1. Open Realm database
        let realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: realmPath))
        let realm = try await Realm(configuration: realmConfig)

        // 2. Create SwiftData context
        let context = ModelContext(modelContainer)

        // 3. Migrate each model type
        try migrateAllTracks(from: realm, to: context)
        try migrateAllAlbums(from: realm, to: context)
        try migrateAllPlaylists(from: realm, to: context)

        // 4. Save all at once
        try context.save()

        print("Migration complete!")
    }

    private func migrateAllTracks(from realm: Realm, to context: ModelContext) throws {
        let realmTracks = realm.objects(RealmTrack.self)

        for realmTrack in realmTracks {
            let sdTrack = Track(
                id: realmTrack.id,
                title: realmTrack.title,
                artist: realmTrack.artist,
                duration: realmTrack.duration,
                genre: realmTrack.genre
            )
            context.insert(sdTrack)
        }
    }

    private func migrateAllAlbums(from realm: Realm, to context: ModelContext) throws {
        let realmAlbums = realm.objects(RealmAlbum.self)

        for realmAlbum in realmAlbums {
            let sdAlbum = Album(
                id: realmAlbum.id,
                title: realmAlbum.title
            )
            context.insert(sdAlbum)

            // Connect relationships after creating all records
            for realmTrack in realmAlbum.tracks {
                if let sdTrack = findTrack(id: realmTrack.id, in: context) {
                    sdAlbum.tracks.append(sdTrack)
                }
            }
        }
    }

    private func findTrack(id: String, in context: ModelContext) -> Track? {
        let descriptor = FetchDescriptor<Track>(
            predicate: #Predicate { $0.id == id }
        )
        return try? context.fetch(descriptor).first
    }
}
複雑なスキーマ移行(変換レイヤー)

複雑なスキーマ、多数の計算プロパティ、またはデータ変換を含むアプリ向け:

// Step 1: Define transformation layer
struct TrackDTO {
    let realmTrack: RealmTrack

    var id: String { realmTrack.id }
    var title: String { realmTrack.title }
    var cleanTitle: String { realmTrack.title.trimmingCharacters(in: .whitespaces) }
    var durationFormatted: String {
        let minutes = Int(realmTrack.duration) / 60
        let seconds = Int(realmTrack.duration) % 60
        return String(format: "%d:%02d", minutes, seconds)
    }
}

// Step 2: Migrate through transformation layer
actor ComplexMigrator {
    let modelContainer: ModelContainer

    func migrateWithTransformation(from realm: Realm) throws {
        let context = ModelContext(modelContainer)

        let realmTracks = realm.objects(RealmTrack.self)
        for realmTrack in realmTracks {
            let dto = TrackDTO(realmTrack: realmTrack)

            // Transform data during migration
            let sdTrack = Track(
                id: dto.id,
                title: dto.cleanTitle,  // Cleaned version
                artist: realmTrack.artist,
                duration: realmTrack.duration
            )
            context.insert(sdTrack)
        }

        try context.save()
    }
}

パート 4:CloudKit 同期への移行

Realm Sync → SwiftData CloudKit

非推奨となった Realm Sync は、自動同期を提供していました。SwiftData は CloudKit を直接使用します。

// REALM SYNC: Automatic but deprecated
let config = Realm.Configuration(
    syncConfiguration: SyncConfiguration(user: app.currentUser!)
)

// SWIFTDATA: CloudKit (recommended replacement)
let schema = Schema([Track.self, Album.self])
let config = ModelConfiguration(
    schema: schema,
    cloudKitDatabase: .private("iCloud.com.example.MusicApp")
)

let container = try ModelContainer(for: schema, configurations: config)
同期ステータスの監視
@MainActor
class CloudKitSyncMonitor: ObservableObject {
    @Published var isSyncing = false
    @Published var lastSyncDate: Date?
    @Published var syncError: Error?

    let modelContainer: ModelContainer

    func startMonitoring() {
        // Monitor CloudKit sync notifications
        NotificationCenter.default.addObserver(
            forName: NSNotification.Name("CloudKitSyncDidComplete"),
            object: nil,
            queue: .main
        ) { [weak self] _ in
            self?.isSyncing = false
            self?.lastSyncDate = Date()
        }
    }

    func syncNow() async {
        isSyncing = true

        do {
            let context = ModelContext(modelContainer)
            // SwiftData sync happens automatically
            // Manually fetch to trigger sync
            let descriptor = FetchDescriptor<Track>()
            _ = try context.fetch(descriptor)
        } catch {
            syncError = error
        }

        isSyncing = false
    }
}
移行のタイミング:Realm Sync → CloudKit
Timeline:
Week 1-2: Development & Testing
├─ Create SwiftData models
├─ Test migrations in non-CloudKit mode
└─ Prepare CloudKit configuration

Week 3: CloudKit Sync Testing
├─ Enable CloudKit in test build
├─ Verify sync works with small datasets
├─ Test multi-device sync
└─ Test conflict resolution

Week 4+: Production Rollout
├─ Deploy app with SwiftData + CloudKit
├─ Initially run parallel (Realm Sync + SwiftData CloudKit)
├─ Monitor both sync mechanisms
├─ Gradually deprecate Realm Sync
└─ Final cutoff before Sept 30, 2025

パート 5:実際の移行シナリオ

シナリオ A:小規模アプリ(10,000 レコード未満)

期間:1〜2 週間 データサイズ:10 MB 未満

// 1. Export Realm data
let realmPath = Realm.Configuration.defaultConfiguration.fileURL!

// 2. Migrate in background task
actor SmallAppMigration {
    let modelContainer: ModelContainer

    func migrateSmallApp() async throws {
        let realmConfig = Realm.Configuration(fileURL: realmPath)
        let realm = try await Realm(configuration: realmConfig)

        let context = ModelContext(modelContainer)

        // All-at-once migration (safe for < 10k records)
        let allTracks = realm.objects(RealmTrack.self)
        for realmTrack in allTracks {
            let track = Track(from: realmTrack)
            context.insert(track)
        }

        try context.save()
        print("✅ Migrated \(allTracks.count) tracks")
    }
}

// 3. Deploy
// Option 1: Migrate on first launch (offline)
// Option 2: Provide manual "Migrate Data" button
// Option 3: Automatic migration in background
シナリオ B:中規模アプリ(100,000〜1,000,000 レコード)

期間:3〜4 週間 データサイズ:100 MB〜1 GB 課題:進捗報告、メモリ管理

actor MediumAppMigration {
    let modelContainer: ModelContainer
    let realmPath: String

    typealias ProgressCallback = (Int, Int) -> Void

    func migrateMediumApp(onProgress: @MainActor ProgressCallback) async throws {
        let realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: realmPath))
        let realm = try await Realm(configuration: realmConfig)

        let context = ModelContext(modelContainer)
        let allTracks = realm.objects(RealmTrack.self)
        let totalCount = allTracks.count

        // Chunk-based migration for memory efficiency
        var count = 0
        for chunk in Array(allTracks).chunked(into: 5000) {
            for realmTrack in chunk {
                let track = Track(from: realmTrack)
                context.insert(track)
            }

            // Save periodically
            try context.save()

            count += chunk.count
            await onProgress(count, totalCount)

            // Check for cancellation
            if Task.isCancelled {
                throw CancellationError()
            }
        }
    }
}

// 4. Show progress UI
@MainActor
class MigrationViewController: UIViewController {
    @IBOutlet weak var progressView: UIProgressView!
    @IBOutlet weak var statusLabel: UILabel!

    func startMigration() {
        Task {
            do {
                try await migrator.migrateMediumApp { current, total in
                    self.progressView.progress = Float(current) / Float(total)
                    self.statusLabel.text = "Migrated \(current) of \(total)..."
                }

                self.statusLabel.text = "✅ Migration complete!"
            } catch {
                self.statusLabel.text = "❌ Migration failed: \(error)"
            }
        }
    }
}
シナリオ C:大規模アプリ(エンタープライズ、1,000,000 レコード超)

期間:6〜8 週間 データサイズ:1 GB 超 課題:ダウンタイムの最小化、データの完全性、ロールバック計画

class EnterpriseGradualMigration {
    let coreDataStack: CoreDataStack  // Existing Realm
    let modelContainer: ModelContainer
    let batchSize = 10000

    // Phase 1: Parallel migration
    func startGradualMigration() async {
        var offset = 0
        let totalRecords = countAllRecords()

        while offset < totalRecords {
            let batch = fetchRealmBatch(limit: batchSize, offset: offset)
            try? await migrateBatch(batch)

            offset += batchSize
            await reportProgress(offset, totalRecords)
        }
    }

    private func migrateBatch(_ batch: [RealmTrack]) async throws {
        let context = ModelContext(modelContainer)

        for realmTrack in batch {
            let track = Track(from: realmTrack)
            context.insert(track)
            track.migrationStatus = .completedPhase1
        }

        try context.save()

        // Give main thread time to breathe
        try await Task.sleep(nanoseconds: 100_000_000)  // 100ms
    }

    // Phase 2: Verify all migrated
    func verifyMigrationComplete() async throws {
        let sdContext = ModelContext(modelContainer)
        let sdCount = try sdContext.fetch(FetchDescriptor<Track>())

        let realmCount = countAllRealmRecords()

        guard sdCount.count == realmCount else {
            throw MigrationError.countMismatch(sd: sdCount.count, realm: realmCount)
        }

        print("✅ Verified: \(sdCount.count) records migrated")
    }

    // Phase 3: Rollback plan
    func rollbackToRealm() {
        // Keep Realm database intact until 100% confident
        // Only delete Realm after running stable on SwiftData for 2+ weeks
    }
}

パート 6:テストと検証

データ完全性チェックリスト

SwiftData を本番環境で稼働させる前に:

@MainActor
class MigrationVerifier {
    func verifyMigration() async throws {
        print("🔍 Running migration verification...")

        // 1. Count verification
        let sdCount = try await countSwiftDataRecords()
        let realmCount = countRealmRecords()
        print("✓ Record count: SD=\(sdCount), Realm=\(realmCount)")

        guard sdCount == realmCount else {
            throw VerificationError.countMismatch
        }

        // 2. Data integrity sampling (spot checks)
        try await verifySampleRecords(count: min(100, sdCount / 10))
        print("✓ Spot checked 100 records - all valid")

        // 3. Relationship integrity
        try await verifyRelationships()
        print("✓ All relationships intact")

        // 4. CloudKit sync test
        try await verifyCloudKitSync()
        print("✓ CloudKit sync working")

        // 5. Performance test
        try await verifyPerformance()
        print("✓ Query performance acceptable")

        print("✅ All verifications passed!")
    }

    private func verifySampleRecords(count: Int) async throws {
        let sdContext = ModelContext(modelContainer)
        let descriptor = FetchDescriptor<Track>()

        let tracks = try sdContext.fetch(descriptor)
        let sample = Array(tracks.prefix(count))

        for track in sample {
            // Verify fields populated
            assert(!track.id.isEmpty, "Track has empty ID")
            assert(!track.title.isEmpty, "Track has empty title")
            assert(track.duration > 0, "Track has invalid duration")
        }
    }

    private func verifyRelationships() async throws {
        let sdContext = ModelContext(modelContainer)

        let albumDescriptor = FetchDescriptor<Album>()
        let albums = try sdContext.fetch(albumDescriptor)

        for album in albums {
            // Verify inverse relationships
            for track in album.tracks {
                assert(track.album?.id == album.id, "Relationship broken")
            }
        }
    }

    private func verifyCloudKitSync() async throws {
        let sdContext = ModelContext(modelContainer)

        // Insert test record
        let testTrack = Track(
            id: "test-" + UUID().uuidString,
            title: "Test Track",
            artist: "Test Artist",
            duration: 240
        )
        sdContext.insert(testTrack)
        try sdContext.save()

        // Verify CloudKit sync initiated
        // (Check iCloud → iPhone → Settings → iCloud for sync status)
        print("ℹ️  Check iCloud app to verify sync initiated")
    }

    private func verifyPerformance() async throws {
        let sdContext = ModelContext(modelContainer)

        let start = Date()

        let descriptor = FetchDescriptor<Track>(
            sortBy: [SortDescriptor(\.title)]
        )
        _ = try sdContext.fetch(descriptor)

        let elapsed = Date().timeIntervalSince(start)
        print("Fetch time: \(String(format: "%.2f", elapsed))s")

        guard elapsed < 2.0 else {
            throw VerificationError.performanceIssue
        }
    }
}

パート 7:トラブルシューティング

一般的な移行時の問題
問題原因解決策
"Property must have default"CloudKit の制約デフォルト値を追加します:var title: String = ""
リレーションシップが同期されない逆方向のリレーションシップがないinverse: \Track.album を追加します
同期が停止するCloudKit の認証に関する問題「設定」→「iCloud」→「CloudKit」を確認します
インポート中にメモリ使用量が増大するチャンク化されていないバッチインポート(1回につき1000件)を実装します
データ損失バックアップがない移行後2週間はRealmのコピーを保持します

パート 8:成功基準

以下を満たした場合、移行は成功です。

  • すべてのデータが正しく移行されている(件数が一致する)
  • サンプルレコードの検証に合格する(100件以上のレコードを抜き取り検査する)
  • リレーションシップが維持されている(逆方向のリレーションシップが機能する)
  • CloudKit の同期が有効で、正常に機能している
  • パフォーマンスが許容範囲内である(クエリが1秒未満)
  • データ競合が発生しない(Swift 6 の厳密な並行処理)
  • 実機でテスト済みである(シミュレーターだけでなく)
  • ロールバック計画が文書化され、テスト済みである
  • Realm データベースをバックアップとして2週間保持する
  • 本番環境で1週間、クラッシュがゼロである

クイックリファレンス:コマンドチェックリスト

# 1. Audit Realm usage
grep -r "RealmTrack\|RealmAlbum" . --include="*.swift"

# 2. Count Realm records (in app)
let realm = try! Realm()
let count = realm.objects(RealmTrack.self).count

# 3. Export Realm database
cp ~/Library/Developer/Realm/my_realm.realm ~/Downloads/backup.realm

# 4. Test SwiftData models
// Create in-memory test container
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Track.self, configurations: config)

# 5. Verify CloudKit
Settings → [Your Name] → iCloud → Check CloudKit status

リソース

WWDC:2024-10137

ドキュメント:/swiftdata

スキル:axiom-swiftdata、axiom-swift-concurrency、axiom-database-migration


作成日:2025-11-30 ステータス:本番環境対応の移行ガイド 緊急度:Realm Device Sync は2025年9月30日に終了 移行所要時間の目安:アプリの複雑さに応じて2~8週間

コメント