I'm using SwiftData in my SwiftUI iOS app, and I need to implement a MigrationPlan
before launching, so things go smoothly later when I need to make changes to the data being saved.
For example, without a migration in place, if I change the type of a property from Bool
to [Bool]
, the app will crash when it tries to load SwiftData, since the saved model doesn't have the right type.
Current Setup
The current @Model
, which is fairly simple by SwiftData standards, except that it has many more properties than I've included here:
@Model
final class MyData
{
var property1: Bool = true
var property2: Bool = true
var property3: Int = 1
// And many more properties...
// Default init.
init()
{
// Assign default values to properties.
self.property1 = true
self.property2 = true
self.property3 = 1
// ...
}
// Dynamic init.
init(
property1: Bool,
property2: Bool,
property3: Int
)
{
self.property1 = property1
self.property2 = property2
self.property3 = property3
// ...
}
}
Creation of the ModelContainer
:
var sharedModelContainer: ModelContainer =
{
let schema = Schema(
[
MyData.self,
])
let modelConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false
)
do
{
return try ModelContainer(
for: schema,
configurations: [modelConfiguration]
)
}
catch
{
fatalError("Could not create ModelContainer: \(error)")
}
}()
@main
struct myApp: App
{
var body: some Scene
{
WindowGroup
{
ContentView()
}
.modelContainer(sharedModelContainer)
}
}
Migration Attempt
To begin implementing the migration, I renamed the model to reflect its version:
@Model
final class MyDataV1
{
// No other changes compared to the code shared above.
}
Then I defined MyDataV2
to represent the new format of the data. The only thing that has changed is the type of property1
:
@Model
final class MyDataV2
{
// Changed to an array.
var property1: [Bool] = [true]
var property2: Bool = true
var property3: Int = 1
// And many more properties...
// Migration init.
init(
myDataV1: MyDataV1
)
{
// Convert to an array.
self.property1 = [myDataV1.property1]
self.property2 = myDataV1.property2
self.property3 = myDataV1.property3
// ...
}
// Default init.
init()
{
// Assign default values to properties.
self.property1 = [true]
self.property2 = true
self.property3 = 1
// ...
}
// Dynamic init.
init(
property1: [Bool],
property2: Bool,
property3: Int
)
{
self.property1 = property1
self.property2 = property2
self.property3 = property3
// ...
}
}
I defined a typealias
to reflect the current version, so other parts of my app don't need to keep updating their references:
typealias MyData = MyDataV2
I defined two VersionedSchema
instances, one to represent V1 and another to represent V2:
enum MyDataSchemaV1: VersionedSchema
{
static var versionIdentifier: Schema.Version = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type]
{
[MyDataV1.self]
}
}
enum MyDataSchemaV2: VersionedSchema
{
static var versionIdentifier: Schema.Version = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type]
{
[MyDataV2.self]
}
}
I defined a SchemaMigrationPlan
to handle migrating from MyDataV1
to MyDataV2
:
enum MyDataMigrationPlan: SchemaMigrationPlan
{
static var schemas: [any VersionedSchema.Type]
{
[MyDataSchemaV1.self, MyDataSchemaV2.self]
}
static var stages: [MigrationStage]
{
[migrateFromV1ToV2]
}
private static var migrationFromV1ToV2Data: [MyDataV1] = []
static let migrateFromV1ToV2 = MigrationStage.custom(
fromVersion: MyDataSchemaV1.self,
toVersion: MyDataSchemaV2.self,
willMigrate:
{
modelContext in
let descriptor : FetchDescriptor<MyDataV1> = FetchDescriptor<MyDataV1>()
let v1Data : [MyDataV1] = try modelContext.fetch(descriptor)
migrationFromV1ToV2Data = v1Data
try modelContext.delete(model: MyDataV1.self)
try modelContext.save()
},
didMigrate:
{
modelContext in
migrationFromV1ToV2Data.forEach
{
myDataV1 in
let myDataV2 = MyDataV2(myDataV1: myDataV1)
modelContext.insert(myDataV2)
}
try modelContext.save()
}
)
}
And finally I updated the ModelContainer
initialization to include the migration plan:
var sharedModelContainer: ModelContainer =
{
let schema = Schema(
[
MyData.self,
])
let modelConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false
)
do
{
return try ModelContainer(
for: schema,
migrationPlan: MyDataMigrationPlan.self,
configurations: [modelConfiguration]
)
}
catch
{
fatalError("Could not create ModelContainer: \(error)")
}
}()
Migration Errors
I created a situation in which an instance of MyDataV1
has already been saved to SwiftData, then I implemented the changes above and launched a new version.
I added logs to the MigrationPlan
and confirmed that willMigrate
starts and finishes without any errors, but the below errors show up before didMigrate
even starts, and cause the app to crash:
CoreData: error: Error: Persistent History (3178) has to be truncated due to the following entities being removed: (
MyDataV1
)
CoreData: warning: Warning: Dropping Indexes for Persistent History
CoreData: warning: Warning: Dropping Transactions prior to 3178 for Persistent History
CoreData: warning: Warning: Dropping Changes prior to TransactionID 3178 for Persistent History
CoreData: error: addPersistentStoreWithType:configuration:URL:options:error: returned error NSCocoaErrorDomain (134060)
CoreData: error: userInfo:
CoreData: error: NSLocalizedFailureReason : Instances of NSCloudKitMirroringDelegate are not reusable and should have a lifecycle tied to a given instance of NSPersistentStore.
CoreData: error: storeType: SQLite
CoreData: error: configuration: default
Attempted Solution 1
I thought that this might be because in willMigrate
I was calling the following:
try modelContext.delete(model: MyDataV1.self)
try modelContext.save()
So I tried removing those lines, but it still caused the same error. This answer has the same implementation with supposedly no errors, so it's not surprising that this wasn't the cause of the issue.
Attempted Solution 2
This answer suggests that the later versions of the schema need to include the previous versions of the model in its models
array. So, I updated the following:
enum MyDataSchemaV2: VersionedSchema
{
static var versionIdentifier: Schema.Version = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type]
{
[MyDataV1.self, MyDataV2.self]
}
}
This doesn't cause the same error, but still crashes with the following error before willMigrate
even begins:
CoreData: error: Attempting to retrieve an NSManagedObjectModel version checksum while the model is still editable. This may result in an unstable verison checksum. Add model to NSPersistentStoreCoordinator and try again.
CoreData: debug: CoreData+CloudKit: -[PFCloudKitOptionsValidator validateOptions:andStoreOptions:error:](36): Validating options: <NSCloudKitMirroringDelegateOptions: 0x600003308960> containerIdentifier:iCloud.myapp.production databaseScope:Private ckAssetThresholdBytes:<null> operationMemoryThresholdBytes:<null> useEncryptedStorage:NO useDeviceToDeviceEncryption:NO automaticallyDownloadFileBackedFutures:NO automaticallyScheduleImportAndExportOperations:YES skipCloudKitSetup:NO preserveLegacyRecordMetadataBehavior:NO useDaemon:YES apsConnectionMachServiceName:<null> containerProvider:<PFCloudKitContainerProvider: 0x600000028280> storeMonitorProvider:<PFCloudKitStoreMonitorProvider: 0x6000000282b0> metricsClient:<PFCloudKitMetricsClient: 0x6000000282c0> metadataPurger:<PFCloudKitMetadataPurger: 0x6000000282d0> scheduler:<null> notificationListener:<null> containerOptions:<null> defaultOperationConfiguration:<null> progressProvider:<NSPersistentCloudKitContainer: 0x6000017af7c0> test_useLegacySavePolicy:YES archivingUtilities:<PFCloudKitArchivingUtilities: 0x6000000282e0> bypassSchedulerActivityForInitialImport:NO bypassDasdRateLimiting:NO activityVouchers:()
storeOptions: {
NSInferMappingModelAutomaticallyOption = 1;
NSMigratePersistentStoresAutomaticallyOption = 1;
NSPersistentCloudKitContainerOptionsKey = "<NSPersistentCloudKitContainerOptions: 0x600002616580>";
NSPersistentHistoryTrackingKey = 1;
NSPersistentStoreMirroringOptionsKey = {
NSPersistentStoreMirroringDelegateOptionKey = "<NSCloudKitMirroringDelegate: 0x600003d3f1b0>";
};
NSPersistentStoreRemoteChangeNotificationOptionKey = 1;
NSPersistentStoreStagedMigrationManagerOptionKey = "<NSStagedMigrationManager: 0x6000002ac6a0>";
}
CoreData: error: addPersistentStoreWithType:configuration:URL:options:error: returned error NSCocoaErrorDomain (134504)
CoreData: error: userInfo:
CoreData: error: NSLocalizedDescription : Cannot use staged migration with an unknown coordinator model version.
CoreData: error: storeType: SQLite
CoreData: error: configuration: default
Where I think this line is the relevant error:
Cannot use staged migration with an unknown coordinator model version.
This answer suggests that the issue is that SwiftData doesn't know from which model to begin migrating. I don't know how that could be the case, or if it is the case, how I could fix it.
Attempted Solution 3
Then, I tried initializing ModelContainer
by giving it both versions, not just the most recent one:
var sharedModelContainer: ModelContainer =
{
let schema = Schema(
[
MyDataV1.self,
MyDataV2.self
])
let modelConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false
)
do
{
return try ModelContainer(
for: schema,
migrationPlan: MyDataMigrationPlan.self,
configurations: [modelConfiguration]
)
}
catch
{
fatalError("Could not create ModelContainer: \(error)")
}
}()
While still excluding these two lines from willMigrate
:
try modelContext.delete(model: MyDataV1.self)
try modelContext.save()
This time, it finished willMigrate
(like in the earliest attempt), but still crashed with a new error immediately after willMigrate
finished:
CoreData: CloudKit: CoreData+CloudKit: -[PFCloudKitStoreMonitor pfcloudstoremonitor_is_holding_your_store_open_waiting_for_cloudkit_activity_to_finish](125): <PFCloudKitStoreMonitor: 0x60000179b140>: Exporter / importer finished after 1 tries. Allowing store to deallocate.
CoreData: error: CoreData+CloudKit: -[NSCloudKitMirroringDelegate _performSetupRequest:]_block_invoke(1240): <NSCloudKitMirroringDelegate: 0x600003d47570>: Failed to set up CloudKit integration for store: <NSSQLCore: 0x10201d870>
Error Domain=NSCocoaErrorDomain Code=134060 "A Core Data error occurred." UserInfo={NSLocalizedFailureReason=The mirroring delegate could not initialize because it's store was removed from the coordinator.}
CoreData: error: CoreData+CloudKit: -[NSCloudKitMirroringDelegate recoverFromError:](2310): <NSCloudKitMirroringDelegate: 0x600003d47570> - Attempting recovery from error: Error Domain=NSCocoaErrorDomain Code=134060 "A Core Data error occurred." UserInfo={NSLocalizedFailureReason=The mirroring delegate could not initialize because it's store was removed from the coordinator.}
CoreData: error: CoreData+CloudKit: -[NSCloudKitMirroringDelegate recoverFromError:]_block_invoke(2329): The store was removed before the mirroring delegate could recover from an error:
Error Domain=NSCocoaErrorDomain Code=134060 "A Core Data error occurred." UserInfo={NSLocalizedFailureReason=The mirroring delegate could not initialize because it's store was removed from the coordinator.}
CoreData: CloudKit: CoreData+CloudKit: -[NSCloudKitMirroringDelegate _finishedRequest:withResult:](3582): Finished request: <NSCloudKitMirroringDelegateSetupRequest: 0x60000213bf70> 9C16248F-EDFB-46BB-ACEF-EAAC55C1CBBA with result: <NSCloudKitMirroringResult: 0x600000c97c60> storeIdentifier: 2FE6E954-8AAF-4D89-8C6C-741C890ADEFC success: 0 madeChanges: 0 error: Error Domain=NSCocoaErrorDomain Code=134060 "A Core Data error occurred." UserInfo={NSLocalizedFailureReason=The mirroring delegate could not initialize because it's store was removed from the coordinator.}
CoreData: CloudKit: CoreData+CloudKit: -[NSCloudKitMirroringDelegate checkAndExecuteNextRequest](3551): <NSCloudKitMirroringDelegate: 0x600003d47570>: Checking for pending requests.
CoreData: error: CoreData+CloudKit: -[NSCloudKitMirroringDelegate _performSetupRequest:]_block_invoke(1302): Failed to finish setup event: Error Domain=NSCocoaErrorDomain Code=134407 "Request '9C16248F-EDFB-46BB-ACEF-EAAC55C1CBBA' was cancelled because the store was removed from the coordinator." UserInfo={NSLocalizedFailureReason=Request '9C16248F-EDFB-46BB-ACEF-EAAC55C1CBBA' was cancelled because the store was removed from the coordinator.}
CoreData: debug: CoreData+CloudKit: -[NSCloudKitMirroringDelegate observeChangesForStore:inPersistentStoreCoordinator:](427): <NSCloudKitMirroringDelegate: 0x600003d07de0>: Observing store: <NSSQLCore: 0x10201f7b0>
BUG IN CLIENT OF CLOUDKIT: Registering a handler for a CKScheduler activity identifier that has already been registered (com.apple.coredata.cloudkit.activity.export.2FE6E954-8AAF-4D89-8C6C-741C890ADEFC).
Where this may be the relevant error:
Error Domain=NSCocoaErrorDomain Code=134060 "A Core Data error occurred." UserInfo={NSLocalizedFailureReason=The mirroring delegate could not initialize because it's store was removed from the coordinator.}
And about 15 lines like this were logged during willMigrate
:
CoreData: debug: CoreData+CloudKit: -[PFCloudKitMetadataModelMigrator calculateMigrationStepsWithConnection:error:](450): Skipping migration for 'ANSCKDATABASEMETADATA' because it already has a column named 'ZLASTFETCHDATE'
Is it a CoreData/SwiftData bug?
This Apple DTS Engineer about 3 weeks ago suggested that these exact errors I'm getting in my Attempted Solution 3 section are framework bugs.
So, what does this mean? SwiftData migration just doesn't work at all if you've also enabled CloudKit sync?
How can I get this working?