Core Data: Migrate to (or from) an app group and data duplication with CloudKit

If you are a iOS developer and you’re looking to share your Core Data with an extension or widget then you might know that you need to share it via an App Group. That’s fine and hopefully works for you. Though I would suggest to be cautious about it and think of other means of sharing data especially with widgets as using Core Data in an App Group can cause some pain which you can read about here from Brent Simmons.

In most cases the TLDR explanation of the approach needed is

  1. Check if the old store URL exists
  2. If so, migrate its data to the new store URL
  3. Delete the data old store URL

That works fine and I’ve left out the detail as there are many examples to be found. I’d even suggest getting Donny Wals’ Practical Core Data and check out chapter 6.

However, if you use a NSPersistentCloudKitContainer then the above appears to work as expected. That is until it syncs with iCloud and you end up with duplicates. You can find some very minimalistic answers on the Apple forums that barely push you towards the right answer. The problem is if you’re a newbie, it’s almost as helpful as no help.

What you should do in this scenario is sort of the same but my testing shows you need to be careful about reference to the old store and ensure you’re doing it at the right point.

So, what I’m seeing working so far is:

  1. Check if the old store URL exits
  2. Replace a new store URL with the data at the old store URL NOTE: think of replace as copy and do this before calling container.loadPersistentStores.
  3. Replace the store description’s reference to the old store URL with the new store URL
  4. Delete the file at the old store URL

Here is some code that should help. A small warning, all testing shows this working fine both in the simulator and on real devices. However, I’m writing this late at night only just after initial testing. I can’t say this is the best way to do it. I’ll update this if I find anything better.

NOTE: this example follows my use case of migrating away from an app group. If you’re going to do a straight copy / past then you’d need to swap the variable names. Assuming someone will copy a paste I’ve added overly verbose logging for any debugging you may want to do.

class PersistenceManagerExample {

    static let shared = PersistenceManagerExample()

    let storeURL: URL
    let description: NSPersistentStoreDescription
    let container: NSPersistentCloudKitContainer

    init(inMemory: Bool = false) {

        /*
         Initial check to allow access to the existing and new persistence store

         This block could be dropped once you're confident it's safe to do so and
         simply use the new URL
         */
        if !FileManager.default.fileExists(atPath: appGroupStoreURL.path) {
            logger.info("[--] using new non-app group URL")
            storeURL = nonAppGroupStoreURL
        } else {
            logger.info("[--] using old app group URL")
            storeURL = appGroupStoreURL
        }

        description = NSPersistentStoreDescription(url: storeURL)
        description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.my.container")

        container = NSPersistentCloudKitContainer(name: "MyModel")
        container.persistentStoreDescriptions = [description]

        // MIGRATION START - this block could be dropped as well.
        migrateStore(for: container)
        // MIGRATION END

        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })

     container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

        // rest of code...
    }

    private func migrateStore(for container: NSPersistentContainer) {

        // you only ever want to run this once so once the old one is gone then drop out.
        if !FileManager.default.fileExists(atPath: appGroupStoreURL.path){
            logger.info("[--] old container gone")
            return
        }

        // see what configuration you're going to apply to the new store
        for persistentStoreDescription in container.persistentStoreDescriptions {
            logger.info("[--] opt \(persistentStoreDescription.options)")
            logger.info("[--] type \(persistentStoreDescription.type)")
            logger.info("[--] conn \(persistentStoreDescription.configuration?.debugDescription ?? "")")
            logger.info("[--] url \(persistentStoreDescription.url?.absoluteString ?? "")")

            do {
                logger.info("[--] copy persistence store")
                try container.persistentStoreCoordinator.replacePersistentStore(
                    at: nonAppGroupStoreURL,  //destination
                    destinationOptions: persistentStoreDescription.options,
                    withPersistentStoreFrom: appGroupStoreURL, // source
                    sourceOptions: persistentStoreDescription.options,
                    ofType: persistentStoreDescription.type
                )
            } catch {
                logger.error("[--] failed to copy persistence store: \(error.localizedDescription)")
            }

        }
        // WARN: it works but feels wrong to me. this is where you should be cautious
        // assumes you have one store while above loop does not.
        container.persistentStoreDescriptions.first!.url = nonAppGroupStoreURL

        do {
            logger.info("[--] remove old url")
            try FileManager.default.removeItem(at: appGroupStoreURL)
        } catch {
            fatalError("Something went wrong while deleting the old store: \(error.localizedDescription)")
        }

        // Used to verify the URL has changed and other config is there
        for persistentStoreDescription in container.persistentStoreDescriptions {
            logger.info("[--] opt \(persistentStoreDescription.options)")
            logger.info("[--] type \(persistentStoreDescription.type)")
            logger.info("[--] conn \(persistentStoreDescription.configuration?.debugDescription ?? "")")
            logger.info("[--] url \(persistentStoreDescription.url?.absoluteString ?? "")")
        }
    }
}

As you can see, it is overly verbose and commented to the hilt but hopefully that will help point you in the right direction and highlights the flaws I think might have better approaches.

Many thanks to Michael Tsai for his blog helping point me in the right direction.