Loading...
Loading...
Use when SwiftData migrations crash, fail to preserve relationships, lose data, or work in simulator but fail on device - systematic diagnostics for schema version mismatches, relationship errors, and migration testing gaps
npx skill4agent add charleswiltgen/axiom axiom-swiftdata-migration-diag// 1. Identify the crash/issue type
// Screenshot the crash message and note:
// - "Expected only Arrays" = relationship inverse missing
// - "incompatible model" = schema version mismatch
// - "Failed to fulfill faulting" = relationship integrity broken
// - Simulator works, device crashes = untested migration path
// Record: "Error type: [exact message]"
// 2. Check schema version configuration
// In your migration plan:
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
// ✅ VERIFY: All versions in order?
// ✅ VERIFY: Latest version matches container?
[SchemaV1.self, SchemaV2.self, SchemaV3.self]
}
static var stages: [MigrationStage] {
// ✅ VERIFY: Migration stages match schema transitions?
[migrateV1toV2, migrateV2toV3]
}
}
// In your app:
let schema = Schema(versionedSchema: SchemaV3.self) // ✅ VERIFY: Matches latest in plan?
let container = try ModelContainer(
for: schema,
migrationPlan: MigrationPlan.self // ✅ VERIFY: Plan is registered?
)
// Record: "Schema version: latest is [version]"
// 3. Check all models included in VersionedSchema
enum SchemaV2: VersionedSchema {
static var models: [any PersistentModel.Type] {
// ✅ VERIFY: Are ALL models listed? (even unchanged ones)
[Note.self, Folder.self, Tag.self]
}
}
// Record: "Missing models? Yes/no"
// 4. Check relationship inverse declarations
@Model
final class Note {
@Relationship(deleteRule: .nullify, inverse: \Folder.notes) // ✅ VERIFY: inverse specified?
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes) // ✅ VERIFY: inverse specified?
var tags: [Tag] = []
}
// Record: "Relationship inverses: all specified? Yes/no"
// 5. Enable SwiftData debug logging
// In Xcode scheme, add argument:
// -com.apple.coredata.swiftdata.debug 1
// Run and check Console for SQL queries
// Record: "Debug log shows: [what you see]"-com.apple.coredata.swiftdata.debug 1// 1. Verify record count matches pre-migration
let context = container.mainContext
let postMigrationCount = try context.fetch(FetchDescriptor<Note>()).count
print("Post-migration count: \(postMigrationCount)")
// Compare to pre-migration count
// 2. Spot-check specific records
let sampleNote = try context.fetch(
FetchDescriptor<Note>(predicate: #Predicate { $0.id == "known-test-id" })
).first
print("Sample note title: \(sampleNote?.title ?? "MISSING")")
// 3. Verify relationships intact
if let note = sampleNote {
print("Folder relationship: \(note.folder != nil ? "✓" : "✗")")
print("Tags count: \(note.tags.count)")
// Verify inverse relationships
if let folder = note.folder {
let folderHasNote = folder.notes.contains { $0.id == note.id }
print("Inverse relationship: \(folderHasNote ? "✓" : "✗")")
}
}
// 4. Check for orphaned data
let orphanedNotes = try context.fetch(
FetchDescriptor<Note>(predicate: #Predicate { $0.folder == nil })
)
print("Orphaned notes (should be 0 if cascade delete worked): \(orphanedNotes.count)")Post-migration count: 1523 // Matches pre-migration
Sample note title: Test Note // Not "MISSING"
Folder relationship: ✓
Tags count: 3
Inverse relationship: ✓
Orphaned notes: 0SwiftData migration problem suspected?
├─ Error: "Expected only Arrays for Relationships"?
│ └─ YES → Relationship inverse missing
│ ├─ Many-to-many relationship? → Pattern 1a (explicit inverse)
│ ├─ One-to-many relationship? → Pattern 1b (verify both sides)
│ └─ iOS 17.0 alphabetical bug? → Pattern 1c (default value workaround)
│
├─ Error: "incompatible model" or crash on launch?
│ └─ YES → Schema version mismatch
│ ├─ Latest schema not in plan? → Pattern 2a (add to schemas array)
│ ├─ Migration stage missing? → Pattern 2b (add stage)
│ └─ Container using wrong schema? → Pattern 2c (verify version)
│
├─ Migration runs but data missing?
│ └─ YES → Data loss during migration
│ ├─ Used didMigrate to access old models? → Pattern 3a (use willMigrate)
│ ├─ Forgot to save in willMigrate? → Pattern 3b (add context.save())
│ └─ Custom migration logic wrong? → Pattern 3c (debug transformation)
│
├─ Works in simulator but crashes on device?
│ └─ YES → Untested migration path
│ ├─ Never tested on real device? → Pattern 4a (real device testing)
│ ├─ Never tested upgrade path? → Pattern 4b (test v1 → v2 upgrade)
│ └─ Production data differs from test? → Pattern 4c (test with prod data)
│
└─ Relationships nil after migration?
└─ YES → Relationship integrity broken
├─ Forgot to prefetch relationships? → Pattern 5a (add prefetching)
├─ Inverse relationship wrong? → Pattern 5b (fix inverse)
└─ Delete rule caused cascade? → Pattern 5c (check delete rules)@Model
final class Note {
var tags: [Tag] = [] // ❌ Missing inverse
}
@Model
final class Tag {
var notes: [Note] = [] // ❌ Missing inverse
}@Model
final class Note {
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = [] // ✅ Inverse specified
}
@Model
final class Tag {
@Relationship(deleteRule: .nullify, inverse: \Note.tags)
var notes: [Note] = [] // ✅ Inverse specified
}@Model
final class Actor {
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var movies: [Movie] // ❌ No default value
}
@Model
final class Movie {
@Relationship(deleteRule: .nullify, inverse: \Actor.movies)
var actors: [Actor] // ❌ No default value
}
// Crashes if "Actor" < "Movie" alphabetically@Model
final class Actor {
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var movies: [Movie] = [] // ✅ Default value
}
@Model
final class Movie {
@Relationship(deleteRule: .nullify, inverse: \Actor.movies)
var actors: [Actor] = [] // ✅ Default value
}enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV3.self] // ❌ Missing V2!
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3] // References V2 but not in schemas
}
}enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self, SchemaV3.self] // ✅ All versions
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3]
}
}static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// ❌ CRASH: SchemaV1.Note doesn't exist here
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
// Data lost because transformation never ran
}
)static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// ✅ SchemaV1.Note exists here
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
// Transform data while old models still accessible
for note in oldNotes {
note.transformed = transformLogic(note.oldValue)
}
try context.save() // ✅ Save before migration completes
},
didMigrate: nil
)# 1. Install v1 on real device
# Build with SchemaV1 as current version
# Run app, create sample data (100+ records)
# 2. Verify data exists
# Check app: should see 100+ records
# 3. Install v2 with migration
# Build with SchemaV2 as current version + migration plan
# Install over existing app (don't delete)
# 4. Verify migration succeeded
# App launches without crash
# Data still exists (100+ records)
# Relationships intactimport Testing
import SwiftData
@Test func testMigrationOnRealDevice() throws {
// This test MUST run on real device, not simulator
#if targetEnvironment(simulator)
throw XCTSkip("Migration test requires real device")
#endif
let container = try ModelContainer(
for: Schema(versionedSchema: SchemaV2.self),
migrationPlan: MigrationPlan.self
)
let context = container.mainContext
let notes = try context.fetch(FetchDescriptor<SchemaV2.Note>())
// Verify data preserved
#expect(notes.count > 0)
// Verify relationships
for note in notes {
if note.folder != nil {
#expect(note.folder?.notes.contains { $0.id == note.id } == true)
}
}
}static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
let notes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
for note in notes {
// ❌ May trigger fault, relationship not loaded
let folderName = note.folder?.name
}
},
didMigrate: nil
)static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
var fetchDesc = FetchDescriptor<SchemaV1.Note>()
// ✅ Prefetch relationships
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
let notes = try context.fetch(fetchDesc)
for note in notes {
// ✅ Relationships already loaded
let folderName = note.folder?.name
let tagCount = note.tags.count
}
try context.save()
},
didMigrate: nil
)| Error Message | Root Cause | Fix | Time |
|---|---|---|---|
| "Expected only Arrays for Relationships" | Many-to-many inverse missing | Add | 2 min |
| "The model used to open the store is incompatible" | Schema version mismatch | Add missing version to | 2 min |
| "Failed to fulfill faulting for [relationship]" | Relationship not prefetched | Add | 3 min |
| App crashes after schema change | Missing model in VersionedSchema | Include ALL models in | 2 min |
| Data lost after migration | Transformation in wrong closure | Move logic from didMigrate to willMigrate | 5 min |
| Simulator works, device crashes | Untested migration path | Test on real device with real data | 15 min |
| Relationships nil after migration | Inverse relationship wrong | Fix | 3 min |
VersionedSchema.modelsSchemaMigrationPlan.schemasinverse:ModelContainer-com.apple.coredata.swiftdata.debug 1willMigratedidMigrateaxiom-swiftdata-migration