Moving a Core Data store without disrupting CloudKit integration

Posted by Davide 

Menu Plan v1.20 has been looong in the making. I had to plan the recipe import feature, test its feasibility, implement it and test the actual app. Still, the greatest obstacle was only incidentally related to recipe import. See, after adding an internal web view for getting recipes, I thought I’d add an extension to allow users to import their recipes from Safari and other browsers.

The problem

As most living beings on planet earth know, iOS app are sandboxed.
This means nothing can access the files inside an app folder. Including the Core Data backing database file. A Share Extension is a separate target: that is, a separate executable with its own folder in the iOS filesystem.
How do access the app database from extension?

App Groups to the rescue

Well, there’s a fairly easy answer, app groups. App groups have been created by Apple for the purpose of sharing data among apps in the same “app family”.
What I had to do was moving the Core Data store file inside the app group.

There is a simple solution, and it is wrong

How difficult can that be? Simply copy the sqlite file to the app group folder and bang, you’re done.
And it won’t work.
Core Data is not an sqlite database. The sqlite database file is essential, but you’re not supposed to interact with it directly. So I googled and read the Apple docs, looking for the proper way to migrate the Core Data store.

Migrate that store, or better, don’t!

There are a number of discussions on Stack Overflow about the proper way to migrate a Core Data store. Most solution use the nifty migratePersistentStore function. It is a nice wee method that works only if invoked after the store has been loaded. Some of the solutions based on migratePersistentStore did seem to work, but eventually failed when cloud kit entered the equation. For some reason I got duplicated data, which could not be deduplicated in any way. Also, whenever v1.2 was installed over an older version and launched, and migration happened, the Core Data-Cloud Kit integration broke: I did not get any remote update notifications from Cloud Kit.

replacePersistentStore, who art thou?

After some seriously depressing sessions of googling, I found this thread on the developer forums:

https://developer.apple.com/forums/thread/682025

An Apple Framework Engineer suggests using replacePersistentStore.
I soon found out this function is shrouded in mystery.

There’s a long post by Tom Harrington focused on the fact that this method is mostly undocumented. This post has a lot of useful info, but I still could not find the proper way to move my store without breaking Core Data and Cloud Kit.

Code-level support

Fortunately I remembered that my yearly subscription to the Apple Developer Program includes 2 Code-level support incidents.
I wrote to Apple, waited a couple of days, and…

The solution

The solution, as always, is simpler than I expected.
It consists of two steps.

1) App launch

Migrate the store if you need to, as soon as possible in the app launch sequence. I added this function at the top of AppDelegate didFinishLaunchingWithOptions


fileprivate func migrateCoreDataStoreToAppGroup() {
      DBLog.info("🧮 Check if need to migrate sqlite file")

      let container = NSPersistentCloudKitContainer(name: "Model")
      guard let storeDescription = container.persistentStoreDescriptions.first else {
          // do something nice instead of this
          fatalError("###\(#function): Failed to retrieve a persistent store description.")
      }

      guard let modelURL = Bundle.main.url(forResource: "Model", withExtension: "momd"), let mom = NSManagedObjectModel(contentsOf: modelURL) else {
          /// do something nice instead of this
          fatalError("###\(#function): Failed to retrieve the managed object model file.")
      }

      // Verify if old file il there
      //
      guard let fromStoreURL = storeDescription.url, FileManager.default.fileExists(atPath: fromStoreURL.path) else {
          DBLog.info("🧮 No need to migrate sqlite")
          return
      }

      DBLog.info("🧮 Need to migrate sqlite file to App Group")

      let toStoreURL = StoreManager.sqliteStoreURL
      let psc = NSPersistentStoreCoordinator(managedObjectModel: mom)
      do {
          try psc.replacePersistentStore(at: toStoreURL, destinationOptions: nil,
                                 withPersistentStoreFrom: fromStoreURL, sourceOptions: nil,
                                 ofType: NSSQLiteStoreType)
          DBLog.info("🧮 Successfully migrate sqlite to app group")

          // Delete old store
          //
          let fileCoordinator = NSFileCoordinator(filePresenter: nil)
          fileCoordinator.coordinate(writingItemAt: fromStoreURL, options: .forDeleting, error: nil, byAccessor: { _ in
              do {
                  try FileManager.default.removeItem(at: fromStoreURL)
                  DBLog.info("🧮 Successfully removed sqlite (non-app-group)")
              } catch {
                  DBLog.error(error.localizedDescription)
              }
          })

      } catch {
          DBLog.error("Cannot replace persistent store \(error)") // Error handling.
      }
  }

Of course you should be careful not to let those fatalError calls into your production code, it’s much wiser to have a fallback. Anyway, use the above code only as reference.

2) In persistentContainer

When initializing your persistentContainer, make sure to use the app group file.


static var appGroupPath: URL {
    FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: StoreManager.appGroupIdentifier)!
}


lazy var persistentContainer: NSPersistentContainer = {

    // 1) create store, use app store group url
    let container = NSPersistentCloudKitContainer(name: "Model")

    // Early if no description is present
    //
    guard let defaultStoreDescription = container.persistentStoreDescriptions.first else {
        // do something nice instead of this
        fatalError("###\(#function): Failed to retrieve a persistent store description.")
    }

    // 2) Set app group store url and make sure to retain all default options
    //
    let appGroupStoreURL = StoreManager.appGroupPath.appendingPathComponent("Model.sqlite")
    DBLog.info("🗄 SQlite url: \(appGroupStoreURL.path)")
    let appGroupStoreDescription = NSPersistentStoreDescription(url: appGroupStoreURL)
		
    // 3) apply options
    //
    for option in defaultStoreDescription.options {
        appGroupStoreDescription.setOption(option.value, forKey: option.key)
    }
		
    // 4) Enable history tracking and remote notifications
    // 

    appGroupStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
    appGroupStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

    appGroupStoreDescription.cloudKitContainerOptions = defaultStoreDescription.cloudKitContainerOptions
    container.persistentStoreDescriptions = [appGroupStoreDescription]

    // 5) Load the container store
    container.loadPersistentStores(completionHandler: { _, error in
        if let error = error as NSError? {
            // Replace this implementation with code to handle the error appropriately.
            fatalError("Unresolved error \(error), \(error.userInfo)")
        }

    })
    // 6) Set merge policies
    container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    container.viewContext.transactionAuthor = appTransactionAuthorName

    // Pin the viewContext to the current generation token and set it to keep itself up to date with local changes.
    container.viewContext.automaticallyMergesChangesFromParent = true
    do {
        try container.viewContext.setQueryGenerationFrom(.current)
    } catch {
        fatalError("###\(#function): Failed to pin viewContext to the current generation:\(error)")
    }
		
    // 7) Observe Core Data remote change notifications.
    //
    NotificationCenter.default.addObserver(
        self, selector: #selector(StoreManager.storeRemoteChange(_:)),
        name: .NSPersistentStoreRemoteChange, object: container.persistentStoreCoordinator)

    return container

}()

As you see, I do a lot of stuff in this implementation:

  1. I create a new NSPersistentStoreDescription
  2. I make sure to use the app group url.
  3. I make sure to let it inherit all the options of the default description.
  4. I assign all the properties that are needed for history tracking.
  5. I load the container store
  6. I set merge policies
  7. I register the notification for managing remote updates.

You might not need all of the tweaks of the code above, but I thought I’d show my full implementation of persistentContainer, so you can see a migration for a CloudKit-backed Core Data stack.

The only think left to do is making sure to properly consume remote changes, deduplicating records as needed; follow Apple’s own guide, the same I linked above.

Phew, quite a long post, hopefully it’ll be useful to some of you guys!

Thanks for reading, now go enjoy your migrated Core Data store!

Categories: coding