axiom-swiftdata-migration-diag について
SwiftData の移行失敗を調査する開発者向けスキルです。よくあるエラーをスキーマ、リレーション、データ変換、テストの問題に分類し、確認手順と修正パターンを示します。
主な機能
- 01エラーと原因の診断チェックリスト
- 02スキーマバージョンとモデルの確認
- 03リレーションの逆参照設定ガイド
- 04移行後のデータ整合性検証
- 05実機での移行テスト
活用シーン
- 01スキーマ変更後のクラッシュ調査
- 02移行後のレコード欠落の調査
- 03リレーション切れや faulting エラーの確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-swiftdata-migration-diagインストール後、Claude Code で/axiom-swiftdata-migration-diagを入力して読み込まれたか確認します。
詳細
SwiftData のマイグレーション診断
概要
SwiftData のマイグレーション失敗は、本番環境でのクラッシュ、データ損失、リレーションシップの破損、またはシミュレーターでのみ成功するといった形で現れます。基本原則 マイグレーション失敗の 90% は、VersionedSchema にモデルが含まれていないこと、リレーションシップの逆参照に関する問題、または未テストのマイグレーション経路が原因であり、SwiftData のバグではありません。
危険信号 — SwiftData のマイグレーション問題を疑う
次のいずれかが見られる場合は、マイグレーション設定の問題を疑ってください。
- スキーマ変更後、アプリの起動時にクラッシュする
- "Expected only Arrays for Relationships" エラー
- "The model used to open the store is incompatible with the one used to create the store"
- "Failed to fulfill faulting for [relationship]"
- シミュレーターではマイグレーションが成功するが、実機ではクラッシュする
- マイグレーション前にはデータが存在したのに、後には消えている
- マイグレーション後にリレーションシップが壊れている(nil でないはずの値が nil になる)
- ❌ 禁止 「SwiftData のマイグレーションは壊れているので、Core Data を使うべきです」
- SwiftData は本番アプリで何百万件ものマイグレーションを処理しています
- スキーマの不一致やリレーションシップのエラーは、常に設定が原因であり、フレームワークの問題ではありません
- 問題を正当化して片付けず、原因を診断してください
重要な違い シミュレーターではビルドのたびにデータベースが削除されるため、スキーマ不一致の問題が隠れます。実機ではデータベースが保持され、スキーマ不一致があるとすぐにクラッシュします。必須:リリース前に、実データを使って実機でマイグレーションをテストしてください。
必須の最初の手順
コードを変更する前に、必ず最初に次を実行してください。
// 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]"
これでわかること
- "Expected only Arrays for Relationships" → パターン 1(リレーションシップの逆参照を修正)へ進みます
- "incompatible model" → パターン 2(スキーマバージョンの不一致)へ進みます
- VersionedSchema にモデルがない → パターン 3(完全なスキーマスナップショット)へ進みます
- シミュレーターでは動作するが、実機ではクラッシュする → パターン 4(マイグレーションのテスト)へ進みます
- マイグレーション後にデータが失われた → パターン 5(willMigrate/didMigrate の誤用)へ進みます
必須の判断
コードを変更する前に、必ず次のいずれかを特定してください。
- エラーが "Expected only Arrays" で、かつリレーションシップの逆参照がない場合 → リレーションシップ設定の問題
- エラーに "incompatible" が含まれ、かつスキーマバージョンが一致しない場合 → バージョンの不一致
- VersionedSchema からモデルが欠落している場合 → 不完全なスキーマスナップショット
- シミュレーターでは成功するが、実機では失敗する場合 → 未テストのマイグレーション経路
- マイグレーション前にはデータが存在するが、後には存在しない場合 → willMigrate/didMigrate の制限に違反
診断結果に矛盾がある場合、または不明瞭な場合
- ここで停止してください。まだパターンに進まないでください
-com.apple.coredata.swiftdata.debug 1を追加して、SQL 出力を確認してください- ファイルシステムを確認してください。.sqlite ファイルは存在しますか?サイズはいくつですか?
- 基準値を確認してください。実際に何が起きているか、想定していた内容とどう違うかを調べてください
マイグレーションの正常完了を確認する
マイグレーションがエラーなしで完了したように見えるものの、データの整合性を確認したい場合は、このセクションを使ってください。
簡単な確認チェックリスト
マイグレーションがクラッシュせずに実行された後:
// 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: 0
次のような表示がある場合:
- レコード数が異なる → データ損失です(willMigrate のロジックを確認してください)
- "MISSING" レコード → スキーマの不一致またはフェッチエラーです
- リレーションシップが nil → 逆参照の設定またはプリフェッチの問題です
- 孤立レコードが 0 より大きい → カスケード削除ルールが機能していません
具体的な修正方法については、以下のパターンを参照してください。
判断ツリー
SwiftData 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)
よくあるパターン
パターン 1a:"Expected only Arrays for Relationships" を修正する
原則 多対多リレーションシップでは、逆参照を明示的に宣言する必要があります。
❌ 誤り("Expected only Arrays" エラーの原因)
@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
}
これが有効な理由 SwiftData では、多対多リレーションシップの結合テーブルを正しく作成するために、逆参照を明示する必要があります。
所要時間 逆参照の宣言を追加するのに 2 分
パターン 1b:iOS 17.0 のアルファベット順バグへの回避策
原則 iOS 17.0 では、モデル名がアルファベット順に並んでいると、多対多リレーションシップが失敗する場合があります。
❌ 誤り(iOS 17.0 でクラッシュします)
@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
✅ 正しい(iOS 17.0 以降で動作します)
@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
}
修正バージョン iOS 17.1 以降
所要時間 デフォルト値の追加に 1 分
パターン 2a:スキーマバージョンの不一致
原則 マイグレーションプランの schemas 配列には、すべてのバージョンを順番に含める必要があります。
❌ 誤り(バージョンが欠けているとクラッシュします)
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]
}
}
所要時間 欠けているバージョンの追加に 2 分
パターン 3a:willMigrate/didMigrate の誤用によるデータ損失
原則 古いモデルにアクセスできるのは willMigrate 内のみで、新しいモデルにアクセスできるのは didMigrate 内のみです。
❌ 誤り(didMigrate 内で古いモデルにアクセスしようとしています)
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
}
)
✅ 正しい(willMigrate 内で変換します)
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
)
所要時間 ロジックを正しいクロージャに移すのに 5 分
パターン 4a:実機テスト
原則 シミュレーターではビルドし直すとデータベースが削除されます。実機ではデータベースが保持されます。
テストワークフロー
# 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 intact
マイグレーションのテストコード
import 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)
}
}
}
所要時間 実機でのテストに 15 分
パターン 5a:整合性を保つためのリレーションシップの事前読み込み
原則 フォルトエラーを避けるため、マイグレーション中にリレーションシップを先読みします。
❌ 誤り(リレーションシップがフォルトし、問題が発生する可能性があります)
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
)
所要時間 事前読み込みの追加に 3 分
クイックリファレンス:エラーメッセージ → 修正方法
| エラーメッセージ | 根本原因 | 修正方法 | 時間 |
|---|---|---|---|
| "Expected only Arrays for Relationships" | 多対多の逆リレーションシップが未指定 | 両側に @Relationship(inverse:) を追加する | 2 分 |
| "The model used to open the store is incompatible" | スキーマバージョンの不一致 | schemas 配列に欠けているバージョンを追加する | 2 分 |
| "Failed to fulfill faulting for [relationship]" | リレーションシップが事前読み込みされていない | relationshipKeyPathsForPrefetching を追加する | 3 分 |
| スキーマ変更後にアプリがクラッシュする | VersionedSchema にモデルが不足している | models 配列にすべてのモデルを含める | 2 分 |
| マイグレーション後にデータが失われる | 誤ったクロージャ内で変換している | ロジックを didMigrate から willMigrate に移す | 5 分 |
| シミュレーターでは動作するが、実機ではクラッシュする | マイグレーションパスが未テスト | 実データを使って実機でテストする | 15 分 |
| マイグレーション後にリレーションシップが nil になる | 逆リレーションシップが誤っている | @Relationship(inverse:) のキーパスを修正する | 3 分 |
デバッグチェックリスト
マイグレーションが失敗した場合は、以下をすべて確認してください。
- すべてのモデルが
VersionedSchema.models配列に含まれている - すべてのスキーマバージョンが
SchemaMigrationPlan.schemas配列に含まれている - マイグレーションステージがスキーマの移行(V1→V2、V2→V3)に対応している
- 多対多のリレーションシップでは、両側に
inverse:が明示的に指定されている - コンテナーが最新の正しいスキーマバージョンで初期化されている
-
ModelContainerの初期化時にマイグレーションプランが登録されている - 実機でテスト済み(シミュレーターだけではない)
- 新規インストールだけでなく、アップグレードパス(v1 → v2)もテスト済み
- SwiftData のデバッグログが有効になっている(
-com.apple.coredata.swiftdata.debug 1) - データ変換ロジックが
willMigrate内にある(didMigrateではない)
30 分経っても行き詰まっている場合
30 分以上かけてもマイグレーションの問題が解決しない場合:
いったん中断してください。次のいずれかです
- 必須の診断を省略した(最もよくある原因)
- 実際の問題を誤って特定した
- 症状に合わないパターンを適用した
- 実機または実データでテストしていない
- 2 段階のマイグレーションが必要な複雑なエッジケースがある
「スキルが機能しなかった」と判断する前に必ず確認する項目
- 必須の最初の手順にあるすべての診断を実行した
- 問題の種類(リレーションシップ、スキーマの不一致、データ損失、テスト不足)を特定した
- SwiftData のデバッグログを有効にし、SQL 出力を確認した
- 実データを使って実機でテストした(シミュレーターではない)
- 判断ツリーで最初に該当するパターンを適用した
- すべてのモデルが VersionedSchema に含まれていることを確認した
- リレーションシップの逆指定を確認した
すべてのボックスにチェックが入っているのに、まだ動かない場合
- 2 段階のマイグレーションが必要です(
axiom-swiftdata-migrationスキルで解説しています) - 所要時間:複雑な型変更のマイグレーションでは 30~60 分
- 「実際に必要なデータ変換は何か」を確認し、2 段階のパターンを実装してください
所要時間の目安
- パターン 1(リレーションシップの逆参照):2~3 分
- パターン 2(スキーマバージョン):2~5 分
- パターン 3(willMigrate の修正):5~10 分
- パターン 4(実機テスト):15~30 分
- パターン 5(リレーションシップの事前読み込み):3~5 分
実際の効果
以前 SwiftData のマイグレーションのデバッグに 1 件あたり 2~8 時間
- 本番環境でアプリの起動時にクラッシュ
- 既存ユーザーのデータ損失
- マイグレーション後にリレーションシップが破損
- シミュレーターでは成功するが、実機では失敗
- 顧客の信頼を損なう
導入後 系統的な診断により 15~45 分
- 診断によって問題の種類を特定(5 分)
- 適切なパターンを適用(5~10 分)
- 実機でテスト(15~30 分)
- 自信を持ってデプロイ
重要なポイント SwiftData には、よくあるあらゆるマイグレーションの問題に対する、確立されたパターンがあります。問題は、開発者がエラーに対してどの診断を適用すべきか分からないことです。
リソース
WWDC:2025-291、2023-10195
ドキュメント:/swiftdata
スキル:axiom-swiftdata-migration、axiom-swiftdata、axiom-database-migration
作成日 2025-12-09 ステータス 本番環境で利用可能な診断パターン フレームワーク SwiftData(Apple) Swift 5.9+