mcpField
A
axiom-swiftdata-migration-diag

axiom-swiftdata-migration-diag

github.com/charleswiltgen数据与数据库1.2k174 次安装今天更新
代码仓库

关于 axiom-swiftdata-migration-diag

面向开发者的 SwiftData 迁移故障诊断技能,将常见错误对应到架构、关系、数据转换或测试问题,并提供检查步骤与修复模式。

主要功能

  1. 01错误与原因诊断清单
  2. 02架构版本和模型检查
  3. 03关系反向配置指导
  4. 04迁移数据完整性验证
  5. 05真机迁移测试

适用场景

  1. 01诊断架构变更后的崩溃
  2. 02排查迁移后的记录丢失
  3. 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)
必须进行的判断

更改任何代码之前,确定以下情况中的一种:

  1. 如果错误为 "Expected only Arrays",且缺少关系反向关系 → 关系配置问题
  2. 如果错误提到 "incompatible",且架构版本不匹配 → 版本不匹配
  3. 如果 VersionedSchema 中缺少模型 → 架构快照不完整
  4. 如果模拟器迁移成功但设备失败 → 迁移路径未经测试
  5. 如果迁移前数据存在、迁移后却不存在 → 违反了 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 字母顺序 Bug 的变通方案

原则 在 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]"未预取关系添加 relationshipKeyPathsForPrefetching3 分钟
架构更改后应用崩溃VersionedSchema 中缺少模型在 models 数组中包含所有模型2 分钟
迁移后数据丢失在错误的闭包中进行转换将逻辑从 didMigrate 移至 willMigrate5 分钟
模拟器运行正常,设备崩溃未测试迁移路径使用真实数据在真实设备上测试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 分钟但迁移问题仍未解决:

停下来。你可能是
  1. 跳过了强制诊断步骤(最常见)
  2. 错误识别了实际问题
  3. 针对你的症状套用了错误模式
  4. 尚未在真实设备/真实数据上测试
  5. 遇到了需要两阶段迁移的复杂边缘情况
声称“技能不起作用”之前的必查清单
  • 我已运行所有“强制首要步骤”中的诊断
  • 我已确定问题类型(关系、架构不匹配、数据丢失、测试缺失)
  • 我已启用 SwiftData 调试日志记录并检查 SQL 输出
  • 我已在使用真实数据的真实设备上测试(不是模拟器)
  • 我已应用决策树中第一个匹配的模式
  • 我已确认 VersionedSchema 中包含所有模型
  • 我已检查关系的反向关系声明
如果所有复选框都已勾选,但问题依然存在
  • 你需要进行两阶段迁移(详见 axiom-swiftdata-migration 技能)
  • 时间成本:复杂类型变更迁移需要 30-60 分钟
  • 请问:“实际需要进行什么数据转换?”并实现两阶段模式

时间成本说明

  • 模式 1(关系反向):2-3 分钟
  • 模式 2(架构版本):2-5 分钟
  • 模式 3(willMigrate 修复):5-10 分钟
  • 模式 4(真机测试):15-30 分钟
  • 模式 5(关系预取):3-5 分钟

实际影响

之前 SwiftData 迁移调试每个问题需要 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+

评论

支持 Markdown