Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"source": "CharlesWiltgen/Axiom",
|
||||
"sourceType": "git",
|
||||
"repoUrl": "https://github.com/CharlesWiltgen/Axiom",
|
||||
"subpath": "axiom-codex/skills/axiom-realm-migration-ref",
|
||||
"installedAt": "2026-04-12T08:06:35.274Z"
|
||||
}
|
||||
793
.claude/skills/axiom-realm-migration-ref/SKILL.md
Normal file
793
.claude/skills/axiom-realm-migration-ref/SKILL.md
Normal file
@@ -0,0 +1,793 @@
|
||||
---
|
||||
name: axiom-realm-migration-ref
|
||||
description: Use when migrating from Realm to SwiftData - comprehensive migration guide covering pattern equivalents, threading model conversion, schema migration strategies, CloudKit sync transition, and real-world scenarios
|
||||
license: MIT
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# Realm to SwiftData Migration — Reference Guide
|
||||
|
||||
**Purpose**: Complete migration path from Realm to SwiftData
|
||||
**Swift Version**: Swift 5.9+ (Swift 6 with strict concurrency recommended)
|
||||
**iOS Version**: iOS 17+ (iOS 26+ recommended)
|
||||
**Context**: Realm Device Sync sunset Sept 30, 2025. This guide is essential for Realm users migrating before deadline.
|
||||
|
||||
---
|
||||
|
||||
## Critical Timeline
|
||||
|
||||
**Realm Device Sync** DEPRECATION DEADLINE = September 30, 2025
|
||||
|
||||
If your app uses Realm Sync:
|
||||
- ⚠️ You MUST migrate by September 30, 2025
|
||||
- ✅ SwiftData is the recommended replacement
|
||||
- ⏰ Time remaining: Depends on current date, but migrations take 2-8 weeks for production apps
|
||||
|
||||
**This guide** provides everything needed for successful migration.
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy Overview
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Pattern Equivalents
|
||||
|
||||
### Model Definition Conversion
|
||||
|
||||
#### Realm → SwiftData: Basic Model
|
||||
|
||||
```swift
|
||||
// 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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key differences**:
|
||||
- Realm: `@Persisted(primaryKey: true)` → SwiftData: `@Attribute(.unique)` (not supported with CloudKit sync — remove if using CloudKit)
|
||||
- Realm: Implicit init → SwiftData: Explicit init required
|
||||
- Realm: `Object` base class → SwiftData: `@Model` macro on `final class`
|
||||
|
||||
#### Realm → SwiftData: Relationships
|
||||
|
||||
```swift
|
||||
// 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
|
||||
}
|
||||
```
|
||||
|
||||
**Key differences**:
|
||||
- Realm: Explicit `RealmSwiftCollection` type → SwiftData: Native `[Track]` array
|
||||
- Realm: Manual relationship management → SwiftData: Inverse relationships automatic
|
||||
- Realm: No delete rules → SwiftData: `deleteRule: .cascade / .nullify / .deny`
|
||||
|
||||
#### Realm → SwiftData: Indexes
|
||||
|
||||
```swift
|
||||
// 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()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Threading Model Conversion
|
||||
|
||||
### Realm Threading → Swift Concurrency
|
||||
|
||||
#### Realm: Manual Thread Handling
|
||||
|
||||
```swift
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Problems**:
|
||||
- Manual DispatchQueue threading error-prone
|
||||
- Easy to access objects on wrong thread
|
||||
- No compile-time guarantees
|
||||
|
||||
#### SwiftData: Actor-Based Concurrency
|
||||
|
||||
```swift
|
||||
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()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Advantages**:
|
||||
- No manual DispatchQueue
|
||||
- Compile-time thread safety
|
||||
- Automatic actor isolation
|
||||
- Swift 6 strict concurrency compatible
|
||||
|
||||
#### Common Threading Patterns
|
||||
|
||||
| Realm Pattern | SwiftData Pattern |
|
||||
|--------------|------------------|
|
||||
| `DispatchQueue.global().async` | `async/await` in background actor |
|
||||
| `realm.write { }` | `context.insert()` + `context.save()` |
|
||||
| Manual thread-local Realm instances | Shared `ModelContainer` + background `ModelContext` |
|
||||
| `Thread.isMainThread` checks | `@MainActor` annotations |
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Schema Migration Strategies
|
||||
|
||||
### Simple Schema Migration (Direct Conversion)
|
||||
|
||||
For apps with simple schemas (< 5 tables, < 10 fields), direct migration is straightforward:
|
||||
|
||||
```swift
|
||||
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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complex Schema Migration (Transformation Layer)
|
||||
|
||||
For apps with complex schemas, many computed properties, or data transformations:
|
||||
|
||||
```swift
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 4: CloudKit Sync Transition
|
||||
|
||||
### Realm Sync → SwiftData CloudKit
|
||||
|
||||
Realm Sync (now deprecated) provided automatic sync. SwiftData uses CloudKit directly:
|
||||
|
||||
```swift
|
||||
// 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)
|
||||
```
|
||||
|
||||
### Sync Status Monitoring
|
||||
|
||||
```swift
|
||||
@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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Migration Timing: 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Real-World Migration Scenarios
|
||||
|
||||
### Scenario A: Small App (< 10,000 Records)
|
||||
|
||||
**Timeline**: 1-2 weeks
|
||||
**Data Size**: < 10 MB
|
||||
|
||||
```swift
|
||||
// 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
|
||||
```
|
||||
|
||||
### Scenario B: Medium App (100,000 - 1,000,000 Records)
|
||||
|
||||
**Timeline**: 3-4 weeks
|
||||
**Data Size**: 100 MB - 1 GB
|
||||
**Challenge**: Progress reporting, memory management
|
||||
|
||||
```swift
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scenario C: Large App (Enterprise, > 1 Million Records)
|
||||
|
||||
**Timeline**: 6-8 weeks
|
||||
**Data Size**: > 1 GB
|
||||
**Challenge**: Minimal downtime, data integrity, rollback plan
|
||||
|
||||
```swift
|
||||
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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Testing & Verification
|
||||
|
||||
### Data Integrity Checklist
|
||||
|
||||
Before going live with SwiftData:
|
||||
|
||||
```swift
|
||||
@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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Troubleshooting
|
||||
|
||||
### Common Migration Issues
|
||||
|
||||
| Issue | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| "Property must have default" | CloudKit constraint | Add defaults: `var title: String = ""` |
|
||||
| Relationships not synced | Missing inverse | Add `inverse: \Track.album` |
|
||||
| Sync stuck | CloudKit auth issue | Check Settings → iCloud → CloudKit |
|
||||
| Memory bloat during import | No chunking | Implement batch import (1000 at a time) |
|
||||
| Data loss | No backup | Keep Realm copy for 2 weeks post-migration |
|
||||
|
||||
---
|
||||
|
||||
## Part 8: Success Criteria
|
||||
|
||||
Your migration is successful when:
|
||||
|
||||
- [ ] All data migrated correctly (count matches)
|
||||
- [ ] Sample record verification passes (spot checks 100+ records)
|
||||
- [ ] Relationships intact (inverse relationships work)
|
||||
- [ ] CloudKit sync enabled and working
|
||||
- [ ] Performance acceptable (queries < 1 second)
|
||||
- [ ] No data races (Swift 6 strict concurrency)
|
||||
- [ ] Tested on real device (not just simulator)
|
||||
- [ ] Rollback plan documented and tested
|
||||
- [ ] Realm database kept as backup for 2 weeks
|
||||
- [ ] Zero crashes in production after 1 week
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Command Checklist
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
**WWDC**: 2024-10137
|
||||
|
||||
**Docs**: /swiftdata
|
||||
|
||||
**Skills**: axiom-swiftdata, axiom-swift-concurrency, axiom-database-migration
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-30
|
||||
**Status**: Production-ready migration guide
|
||||
**Urgency**: Realm Device Sync sunset September 30, 2025
|
||||
**Estimated Migration Time**: 2-8 weeks depending on app complexity
|
||||
@@ -0,0 +1,3 @@
|
||||
interface:
|
||||
display_name: "Realm Migration Reference"
|
||||
short_description: "Migrating from Realm to SwiftData"
|
||||
Reference in New Issue
Block a user