Fun With SwiftData and CloudKit!

Several of my apps rely on SwiftData and sync across devices via CloudKit. Like many things in software development this can be simple, fun and like magic. Until it isn’t.

SwiftData makes it very easy to add persistence to your apps. Just slap that @Model attribute on your class and it will be written to the disc on your device. With a bit of configuration you can add CloudKit support and your data will sync across your devices. Both of these parts are magic if you’ve ever done any of this by yourself.

Like many things with software development things usually work if you take the golden path. Veer off that path and you may need to find your own way or you may just crash. 

When these frameworks give us “magic” it can be hard to find your way back to the path or even figure out how lost you are. Today’s internet of slop makes it even harder. You may find dozens of examples and tutorials explaining how things work but all too often those resources are all copying from a single source, very shallow or in the worst cases of slop, simply wrong or hallucinations.

In my most recent update work for my app Peakist I am adding some new features that require me to add new data models that need to be persisted and synced across devices. Apple provides methods for migrating between data Schemas but sometimes foolish developers make their own lives harder but not following the golden path from the beginning.

It’s me. I’m the foolish developer.

I figured I would share what I did wrong, how I resolved it and what I learned.

Yes, you can just slap @Model onto a Class and it becomes a persisted object record, reading and writing its data to your drive. You can configure your ModelContainer to use CloudKit to sync these models between devices. Apple will do all the dirty work and it will be awesome.

Unless you just slap that @Model attribute on your class. Don’t just slap @Model on your class. Add @Model at the same time you add all your models to a Versioned Schema. Surely, since I wrote the referenced post on using migrations I wouldn’t just slap @Model on there and move on. Yet this is exactly what I did. Peakist started as an app just for me, a quick throwaway project. It evolved into something I offered to the public via the App Store and I never wrestled my models into a proper Schema.

You will see examples telling you to just put the unversioned models into a proper schema and then from there start adding your new schema(s). This may even appear to work while debugging or even via TestFlight. And then it won’t work.

The typical pattern when using Versioned Schemas is to create your ModelContainer and provide it with any migrations necessary to bring your database up to date. That may all appear to work, taking your naughty unversioned models, versioning them and then updating the Schema to add new records or updating the existing records. Then at some point your app will crash on launch. 

If you follow the prescribed path of creating a ModelConfiguration with your schema and migrations and feeding that into a ModelContainer you will usually see a try-catch block that triggers a FatalError if the Container is not created. Makes sense, your app would be useless without the data. Unfortunately that will return crash logs that have a handful of lines in the stack trace because your app doesn’t get very far.

Further complicating things Xcode, where you will do most of your debugging and testing, will work with the development branch of your CloudKit store. CloudKit has two data stores each with their own version of your Schema. This is a good thing. You can create, update and delete the Schema in the Development environment as you wish. Your Development Schema will be created for you, again magic, just from those handy @Model attributes in your code.

Sometimes you will mess up the magic. Perhaps you add a record or field to a Model class then remove it. The Schema may become confused and your app may have errors or crash. You can go into the CloudKit console and reset the environment at any point and the current Production Schema will be copied into the Development environment. If you are seeing odd issues with your app revolving around SwiftData or CloudKit feel free to nuke that environment and start over. The next time your run the app in Xcode/Simulator the Schema will be updated to match your actual code. Tip number one is nuke the Development environment and nuke often. Don’t spend hours trying to figure out some strange CloudKit error only to find out at the end that you got your Schema all mixed up.

Returning to the ModelContainer and the dreaded FatalError, it’s important to know which environment is used when you run your app in different circumstances. By default, running a build via Xcode will use the Development environment. This is good. you won’t hurt any users data currently in production while you mess things up. The released app will use Production. This should be obvious. What may not be obvious is you need to promote the Development environment to Production for any build that goes through App Store Connect. Of course a submission for release in the store would use Production but so does App Review and more importantly TestFlight.

Makes sense. But what if, like me you are only seeing errors in these builds using Production? How do you debug that when the stack traces come back with 8 lines of code and no real actionable information?

You can add a field to your info.plist that will use the Production environment in your debug builds. Adding:

com.apple.developer.icloud-container-environment = Production  

in your info.plist will cause the app to use the Production environment.  Thats tip two. May or may not be helpful. Your mileage may vary.

No need to fear corrupting the Production Schema. All that magic that occurs in the Development environment can’t happen in Production. So if you change a Model definition the Schema will not update in Production. This is a good thing. If you need to make changes drop back to Development and repeat as needed.

Getting back to me creating a shipping app with an unversioned Schema we still have app builds that seem to work until they don’t. I would install the builds with unversioned models than over that install a build that should migrate into a nice clean versioned Schema. It would work until the app was fully closed (force quit). Start the app again and assuming you could even get at the error you would see something like “I have no idea what those models are so I can’t migrate nor create that container” Boom. FatalError. App crash.

That error in SwiftData terms is SwiftDataError.loadIssueModelContainer. Notice I don’t link to the documentation for that as there is a page but no information. Cool.

In my case what appears to happen is the migration goes fine, the app launches and works as expected with all my new features and shiny new model updates. Until you fully close the app and relaunch. Then SwiftData acts like it’s never seen this data before and is very offended. Boom. FatalError.

As I said above you will come across discussions that just tell you to take unversioned Model1, Model2… and wrap them in a Versioned Schema as V1 then proceed with V2, etc. That may seem to work or even work for some. Not me.

The “fix” I came up with was to check for the SwiftDataError.loadIssueModelContainer error and if encountered create a new, separate model container with only those models that were unversioned. I’m not using that Container beyond defining those models in the Container. After that I call the same code to try to create my container again. Recursively. 

You may see a flaw there as you may enter an infinite loop. I pass a boolean into the CreateContainer() method indicating if it has already failed once. If we have failed once already just give up.

Pseudo Code:

 

func container(failed: Bool = false) -> ModelContainer {

   let schema = Schema()

 

   let modelConfiguration = ModelConfiguration(schema: schema,

                                                    isStoredInMemoryOnly: false)

   do {

      let container = try ModelContainer(for: schema,

                                          migrationPlan: ,

                                          configurations: [modelConfiguration])

      return container

  } catch {

      return tryContainerAgain(error)

  }

 

  func tryContainerAgain(_ err: Error) -> ModelContainer {

     // if we’ve already failed once just give up

     if failed {

        fatalError("Could not create ModelContainer: \(err)")

     }

 

     // create a container with just the problem models.

     // do not return this container for use

     // this is only to setup the migration/container creation that previously failed

     let oldSchema = Schema()

     _ = try? ModelContainer(for: oldSchema,

                             migrationPlan: nil,

                             configurations: ModelConfiguration(schema: oldSchema,

                             isStoredInMemoryOnly: false))

      // then try again

      return container(failed: true)

   }

}

This works.

This does not inspire confidence. I’m unclear what issues this may create later. I don’t think it’s destructive but it may come up again later if SwiftDataError.loadIssueModelContainer is triggered for a new reason. My temporary container only creates the V1 models necessary to get things versioned. It looks like worst case scenario is in the future I need to deal with a new cause of the FatalError.

Ideally you would be able to query the current local store and say “hey what data models do you have and are they versioned” That seems like something you could do except for the fact that the unversioned models are exactly the same as the Versioned V1 models and SwiftData will say “who?, what?"

This can all be nerve wracking if you have an app in the wild with real users and real data and you don’t want to break things and suffer the bile and one star reviews. It’s important to understand a bit of how SwiftData/CloudKit works. Things like those annoying requirements that Model values need to have defaults or be optional may make more sense when you think about users on version 1.2.1 of your app and version 3.2 at the same time. The Schema is additive. So the earlier users keep working with their older data and the new users get the shiny newness. A proper Schema was ready for this the whole time. Optional means just that. That data may not be present. May seem unnecessary when you start. May make more sense as your code grows.

To help ease your mind, the CloudKit Console will show you the Diff (changes) between Schemas when you begin to promote Development to Production. You will see that there are not really as many differences as you may think. Also things like Transient values don’t appear in the Schema due to their Transient nature (makes sense) but nor will Relationships. There is some magic there that you don’t have to worry about. Follow the rules for creating Relationships, warts and all, and you should be ok.

So this is 1800 words retelling my work this week. It’s better than 9000 Mastodon Toots.

By the time you read this it’s entirely possible things are broken in new ways. I still think this might help someone out there at least to think about their code in a new way if they encounter any of these issues. At the absolute minimum, this was written by a standard flawed human and not some AI slopster.

If you have suggestions or just want to tell me I’m a fool, which I admitted above, hit me up on Mastodon, link below

Patrick McConnell @pmcconnell
🐘 Reply on Mastodon