EP 341 · CloudKit Sync · Oct 13, 2025 ·Members

Video #341: CloudKit Sync: The SyncEngine

smart_display

Loading stream…

Video #341: CloudKit Sync: The SyncEngine

Episode: Video #341 Date: Oct 13, 2025 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep341-cloudkit-sync-the-syncengine

Episode thumbnail

Description

With our database migrated, it’s time to take the SyncEngine for a spin to see how it seamlessly synchronizes data to and from iCloud, how it resolves conflicts when records are edited and deleted from multiple devices, and even how records are synchronized from different versions of the application and database schema.

Video

Cloudflare Stream video ID: 74f69b7ef951b2bca05c78386185cde7 Local file: video_341_cloudkit-sync-the-syncengine.mp4 *(download with --video 341)*

References

Transcript

0:05

OK, we have now seen the real world troubles associated with trying to take an existing application and preparing it to be synchronized to CloudKit. In short, it takes some work. If your app is using simple integer primary keys then you have to perform a multi-step process to update all of those IDs to be globally unique identifiers, such as a UUID. And if you have any tables that don’t have a primary key at all, which can be common with join tables, then you have to migrate them to add a primary key.

0:32

It’s honestly a lot of work to do these kinds of migrations, and a bit scary since your user’s data is at risk. One small typo and you run the risk of corrupting their data. And so that’s why we also designed a tool that specifically aids in this kind of migration. You can simply provide a list of your tables that you want to migrate, and the tool takes care of analyzing the current schema to perform the multi-step migration process of changing primary keys to UUIDs and adding primary keys to tables that don’t have them. It even takes care to restore any indices or triggers after the migration is done. Brandon

1:02

But we’ve done all of this work to prepare our app for synchronization and we haven’t actually gotten to sync our data yet! But that’s ok, because on Point-Free we don’t shy away from showing our views the gritty, dark underbelly of app development. These are the kinds of problems we are faced every day, and we don’t all have the benefit of constantly starting with beautiful greenfield projects.

1:22

And with our bit of prep work done, we are now ready to actually install a sync engine into our application and unleash the true powers of SQLiteData.

1:33

Let’s take a look. Installing the SyncEngine

1:36

Recall that previously we had tried to construct a SyncEngine and assign it to the defaultSyncEngine dependency in the entry point of the app. Previously that did not compile because our tables had integer primary keys. But now they have proper UUID primary keys, and so we can bring back this code: $0.defaultSyncEngine = try! SyncEngine( for: $0.defaultDatabase, tables: Reminder.self, RemindersList.self, Tag.self, ReminderTag.self )

2:12

And everything compiles.

2:17

And truly, that is all it takes to immediately start synchronizing data to iCloud for this app. If we launch the app we will see that all of our reminders and lists are still there. However, if we check out the logs we will see a bunch of messages like this: <CKSyncEngine Shared> error fetching changes for context <FetchChangesContext reason=scheduled options=<FetchChangesOptions scope=all group=CKSyncEngine-FetchChanges-Manual groupID=FC62F2B4974DF420)>>: <CKError 0x600000c2d320: "Not Authenticated" (9); "Could not determine iCloud account status">

2:52

This is happening because we aren’t actually logged into an iCloud account in the simulator. But that’s OK. The sync engine still does its work even when a user isn’t logged in, and it will listen for the moment that the user does log in, and use that as an opportunity to upload all of the local data to iCloud.

3:11

To demonstrate this, I’m going to hop over to settings, log into my iCloud account, and then hop back over to the app. And nothing seems to have changed really. All of our reminders and lists are still sitting there. But something magical has happened behind the scenes. The SyncEngine has detected that we just logged into our iCloud account, and immediately found all local records that are not sync’d with CloudKit and uploaded them.

3:57

And in fact, there are a whole bunch of new logs that have been output to the console letting us know that quite a bit of activity has taken place. These logs are printed by the SyncEngine to make it easier for us and our users to debug what is going on inside. And if you ever want to hide these logs you can provide the logger argument with a disabled logger: logger: Logger(.disabled)

4:32

But right now these logs are important to us because they give us insight into what is happening. We can filter these logs by “SQLiteData”, which is what is printed at the beginning of all events related to the internals of the library.

4:43

After filtering the logs we will see the full story told: SQLiteData (private.db) willFetchChanges SQLiteData (private.db) didFetchChanges SQLiteData (private.db) willSendChanges: manual SQLiteData (private.db) didSendChanges: manual SQLiteData (private.db) signIn Current user: _0xd3adc4febe3f06e77ab00717feed5e.__defaultOwner__._defaultZone SQLiteData (private.db) stateUpdate SQLiteData (private.db) stateUpdate SQLiteData (private.db) willFetchChanges SQLiteData (private.db) stateUpdate SQLiteData (private.db) stateUpdate SQLiteData (private.db) didFetchChanges SQLiteData (private.db) willSendChanges: scheduled SQLiteData (private.db) stateUpdate SQLiteData (private.db) sentDatabaseChanges ┏━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ zoneName ┃ ownerName ┃ ┡━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩ │ ✔︎ Saved │ co.pointfree.SQLiteData.defaultZone │ __defaultOwner__ │ └─────────┴─────────────────────────────────────┴──────────────────┘ SQLiteData (private.db) nextRecordZoneChangeBatch ┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ event ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ → Sending │ reminderTags │ 91974ac5-b5ab-4923-8648-344aaaa0394e:reminderTags │ │ → Sending │ reminderTags │ dfa9e23a-dc14-45a2-b954-3d503269f9b2:reminderTags │ │ → Sending │ reminderTags │ ef88761b-17e6-4255-aa12-7b0698393ac8:reminderTags │ │ → Sending │ reminderTags │ 6d7df5ea-6805-471e-af30-f18569495d67:reminderTags │ │ → Sending │ reminderTags │ d8857162-1bc2-41f1-a6b0-47c8b1283dd2:reminderTags │ │ → Sending │ reminderTags │ 487c316e-e4ae-4310-807b-ce5b89fb2872:reminderTags │ │ → Sending │ reminderTags │ be5212b3-9d17-46e0-bcb6-64ec5b2af1ee:reminderTags │ │ → Sending │ reminderTags │ ec915784-2271-4f64-a96d-36b1cf68ae2a:reminderTags │ │ → Sending │ reminderTags │ 7cef3a7f-a603-4a84-a61b-c18e3bd91d35:reminderTags │ │ → Sending │ reminders │ 479ead8a-5a2f-a90e-3eff-8e3298c688c0:reminders │ │ → Sending │ reminders │ b0914fd5-6a7f-44e1-4e83-195aaaf329db:reminders │ │ → Sending │ reminders │ 5fc22aa6-2ac8-cf06-becb-e7e72efb5387:reminders │ │ → Sending │ reminders │ 5eb73cce-8e3e-18f9-f2b6-e30575d454d9:reminders │ │ → Sending │ reminders │ 19c9a720-6bc2-48c0-3cf7-3744ab8783f2:reminders │ │ → Sending │ reminders │ 4596fa2a-dac6-9fc1-ed13-c74a7194b4ca:reminders │ │ → Sending │ reminders │ c9f782ed-1a6d-c9bc-9c91-ee6b1bcfdc88:reminders │ │ → Sending │ reminders │ f374e36f-6c1b-0d6a-5315-3e6f24410dcd:reminders │ │ → Sending │ reminders │ a8b9da48-3ba8-1bfe-516d-75e125ff427b:reminders │ │ → Sending │ reminders │ fc2a4739-580e-82ca-8c33-2a63eaf407b7:reminders │ │ → Sending │ remindersLists │ f02f5728-794b-3aae-5fcd-baec6ac5d58f:remindersLists │ │ → Sending │ remindersLists │ bbb7f7eb-4f5b-795c-befa-dfea03aaa1f3:remindersLists │ │ → Sending │ remindersLists │ 5db46c0e-6c7e-e9e4-c029-31573f7da70e:remindersLists │ │ → Sending │ tags │ 521118a5-9b6f-6a79-b28d-67d8991823e5:tags │ │ → Sending │ tags │ 29b8772b-5be6-3c36-3266-f26fb258fae1:tags │ │ → Sending │ tags │ bb5ea083-a9fd-0736-b388-6511bb464f14:tags │ │ → Sending │ tags │ 3fa7f64b-8293-114c-12f5-84d8e9fd4605:tags │ │ → Sending │ tags │ 1d9f0239-a89e-0b3e-6c12-e2896c1972a4:tags │ │ → Sending │ tags │ ce1be00e-24e5-8289-0e63-376036c5dd78:tags │ └───────────┴────────────────┴─────────────────────────────────────────────────────┘ SQLiteData (private.db) stateUpdate SQLiteData (private.db) sentRecordZoneChanges ┏━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ✔︎ Saved │ reminderTags │ 487c316e-e4ae-4310-807b-ce5b89fb2872:reminderTags │ │ ✔︎ Saved │ reminderTags │ 6d7df5ea-6805-471e-af30-f18569495d67:reminderTags │ │ ✔︎ Saved │ reminderTags │ ef88761b-17e6-4255-aa12-7b0698393ac8:reminderTags │ │ ✔︎ Saved │ reminderTags │ dfa9e23a-dc14-45a2-b954-3d503269f9b2:reminderTags │ │ ✔︎ Saved │ reminderTags │ d8857162-1bc2-41f1-a6b0-47c8b1283dd2:reminderTags │ │ ✔︎ Saved │ reminderTags │ 7cef3a7f-a603-4a84-a61b-c18e3bd91d35:reminderTags │ │ ✔︎ Saved │ reminderTags │ be5212b3-9d17-46e0-bcb6-64ec5b2af1ee:reminderTags │ │ ✔︎ Saved │ reminderTags │ 91974ac5-b5ab-4923-8648-344aaaa0394e:reminderTags │ │ ✔︎ Saved │ reminderTags │ ec915784-2271-4f64-a96d-36b1cf68ae2a:reminderTags │ │ ✔︎ Saved │ reminders │ c9f782ed-1a6d-c9bc-9c91-ee6b1bcfdc88:reminders │ │ ✔︎ Saved │ reminders │ fc2a4739-580e-82ca-8c33-2a63eaf407b7:reminders │ │ ✔︎ Saved │ reminders │ 5fc22aa6-2ac8-cf06-becb-e7e72efb5387:reminders │ │ ✔︎ Saved │ reminders │ a8b9da48-3ba8-1bfe-516d-75e125ff427b:reminders │ │ ✔︎ Saved │ reminders │ 19c9a720-6bc2-48c0-3cf7-3744ab8783f2:reminders │ │ ✔︎ Saved │ reminders │ 4596fa2a-dac6-9fc1-ed13-c74a7194b4ca:reminders │ │ ✔︎ Saved │ reminders │ f374e36f-6c1b-0d6a-5315-3e6f24410dcd:reminders │ │ ✔︎ Saved │ reminders │ 479ead8a-5a2f-a90e-3eff-8e3298c688c0:reminders │ │ ✔︎ Saved │ reminders │ 5eb73cce-8e3e-18f9-f2b6-e30575d454d9:reminders │ │ ✔︎ Saved │ reminders │ b0914fd5-6a7f-44e1-4e83-195aaaf329db:reminders │ │ ✔︎ Saved │ remindersLists │ f02f5728-794b-3aae-5fcd-baec6ac5d58f:remindersLists │ │ ✔︎ Saved │ remindersLists │ bbb7f7eb-4f5b-795c-befa-dfea03aaa1f3:remindersLists │ │ ✔︎ Saved │ remindersLists │ 5db46c0e-6c7e-e9e4-c029-31573f7da70e:remindersLists │ │ ✔︎ Saved │ tags │ ce1be00e-24e5-8289-0e63-376036c5dd78:tags │ │ ✔︎ Saved │ tags │ 521118a5-9b6f-6a79-b28d-67d8991823e5:tags │ │ ✔︎ Saved │ tags │ bb5ea083-a9fd-0736-b388-6511bb464f14:tags │ │ ✔︎ Saved │ tags │ 1d9f0239-a89e-0b3e-6c12-e2896c1972a4:tags │ │ ✔︎ Saved │ tags │ 3fa7f64b-8293-114c-12f5-84d8e9fd4605:tags │ │ ✔︎ Saved │ tags │ 29b8772b-5be6-3c36-3266-f26fb258fae1:tags │ └─────────┴────────────────┴─────────────────────────────────────────────────────┘ SQLiteData (private.db) stateUpdate SQLiteData (private.db) didSendChanges SQLiteData (private.db) didSendChanges SQLiteData (private.db) stateUpdate

4:50

There’s a lot going on in this logs, but understanding how these logs are structured will give you valuable insight into how your data is being sent to CloudKit and how data is being received from CloudKit.

5:06

First, the “ (private.db) ” part of the logs is to let us know that that particular log is related to the private CloudKit database, which is the database where all of the data for the current iCloud user is stored. Later on in this series we will show how to share data with other iCloud users, and at that time you will start to see “(shared.db)” showing up in the logs.

5:31

Next, the “ will ” and “ did ” events for fetching and sending changes are just notifications that the sync engine is about to do something or did something. But there is no auxiliary data associated with these events. Then there’s a signIn event: SQLiteData (private.db) signIn Current user: _0xd3adc4febe3f06e77ab00717feed5e.__defaultOwner__._defaultZone This is emitted when the SyncEngine detects that the user just logged into their iCloud account. There is also a corresponding event for logging out and switching accounts. And then our first meaty event is sentDatabaseChanges : SQLiteData (private.db) sentDatabaseChanges ┏━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ zoneName ┃ ownerName ┃ ┡━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩ │ ✔︎ Saved │ co.pointfree.SQLiteData.defaultZone │ __defaultOwner__ │ └─────────┴─────────────────────────────────────┴──────────────────┘ This is letting us know that a zone was successfully saved, and the zone that was saved is the default that is used by the SyncEngine . This zone is customizable when creating a SyncEngine , but there aren’t a lot of reasons to use a custom zone.

5:40

The next interesting event is nextRecordZoneChangeBatch , and it’s a big one: SQLiteData (private.db) nextRecordZoneChangeBatch ┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ event ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ → Sending │ reminderTags │ 91974ac5-b5ab-4923-8648-344aaaa0394e:reminderTags │ │ → Sending │ reminderTags │ dfa9e23a-dc14-45a2-b954-3d503269f9b2:reminderTags │ │ → Sending │ reminderTags │ ef88761b-17e6-4255-aa12-7b0698393ac8:reminderTags │ │ → Sending │ reminderTags │ 6d7df5ea-6805-471e-af30-f18569495d67:reminderTags │ │ → Sending │ reminderTags │ d8857162-1bc2-41f1-a6b0-47c8b1283dd2:reminderTags │ │ → Sending │ reminderTags │ 487c316e-e4ae-4310-807b-ce5b89fb2872:reminderTags │ │ → Sending │ reminderTags │ be5212b3-9d17-46e0-bcb6-64ec5b2af1ee:reminderTags │ │ → Sending │ reminderTags │ ec915784-2271-4f64-a96d-36b1cf68ae2a:reminderTags │ │ → Sending │ reminderTags │ 7cef3a7f-a603-4a84-a61b-c18e3bd91d35:reminderTags │ │ → Sending │ reminders │ 479ead8a-5a2f-a90e-3eff-8e3298c688c0:reminders │ │ → Sending │ reminders │ b0914fd5-6a7f-44e1-4e83-195aaaf329db:reminders │ │ → Sending │ reminders │ 5fc22aa6-2ac8-cf06-becb-e7e72efb5387:reminders │ │ → Sending │ reminders │ 5eb73cce-8e3e-18f9-f2b6-e30575d454d9:reminders │ │ → Sending │ reminders │ 19c9a720-6bc2-48c0-3cf7-3744ab8783f2:reminders │ │ → Sending │ reminders │ 4596fa2a-dac6-9fc1-ed13-c74a7194b4ca:reminders │ │ → Sending │ reminders │ c9f782ed-1a6d-c9bc-9c91-ee6b1bcfdc88:reminders │ │ → Sending │ reminders │ f374e36f-6c1b-0d6a-5315-3e6f24410dcd:reminders │ │ → Sending │ reminders │ a8b9da48-3ba8-1bfe-516d-75e125ff427b:reminders │ │ → Sending │ reminders │ fc2a4739-580e-82ca-8c33-2a63eaf407b7:reminders │ │ → Sending │ remindersLists │ f02f5728-794b-3aae-5fcd-baec6ac5d58f:remindersLists │ │ → Sending │ remindersLists │ bbb7f7eb-4f5b-795c-befa-dfea03aaa1f3:remindersLists │ │ → Sending │ remindersLists │ 5db46c0e-6c7e-e9e4-c029-31573f7da70e:remindersLists │ │ → Sending │ tags │ 521118a5-9b6f-6a79-b28d-67d8991823e5:tags │ │ → Sending │ tags │ 29b8772b-5be6-3c36-3266-f26fb258fae1:tags │ │ → Sending │ tags │ bb5ea083-a9fd-0736-b388-6511bb464f14:tags │ │ → Sending │ tags │ 3fa7f64b-8293-114c-12f5-84d8e9fd4605:tags │ │ → Sending │ tags │ 1d9f0239-a89e-0b3e-6c12-e2896c1972a4:tags │ │ → Sending │ tags │ ce1be00e-24e5-8289-0e63-376036c5dd78:tags │ └───────────┴────────────────┴─────────────────────────────────────────────────────┘

5:51

This event is what is emitted when the sync engine has gathered a batch of records to send off to CloudKit. This table lets us know that it successfully sent all of these records, and we get the type and name of each record. The reason so many records are being sent is because when the sync engine detects a fresh iCloud user log in, it takes all of the local records stored on the device and uploads them. This makes it possibly to seamlessly upgrade your app to add CloudKit syncing, and the first time your users run that version of the app their data will automatically be uploaded without you having to lift a finger.

6:27

And then a moment later we get confirmation from CloudKit that indeed all of those records were accepted and saved via the sentRecordZoneChanges : SQLiteData (private.db) sentRecordZoneChanges ┏━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ✔︎ Saved │ reminderTags │ 487c316e-e4ae-4310-807b-ce5b89fb2872:reminderTags │ │ ✔︎ Saved │ reminderTags │ 6d7df5ea-6805-471e-af30-f18569495d67:reminderTags │ │ ✔︎ Saved │ reminderTags │ ef88761b-17e6-4255-aa12-7b0698393ac8:reminderTags │ │ ✔︎ Saved │ reminderTags │ dfa9e23a-dc14-45a2-b954-3d503269f9b2:reminderTags │ │ ✔︎ Saved │ reminderTags │ d8857162-1bc2-41f1-a6b0-47c8b1283dd2:reminderTags │ │ ✔︎ Saved │ reminderTags │ 7cef3a7f-a603-4a84-a61b-c18e3bd91d35:reminderTags │ │ ✔︎ Saved │ reminderTags │ be5212b3-9d17-46e0-bcb6-64ec5b2af1ee:reminderTags │ │ ✔︎ Saved │ reminderTags │ 91974ac5-b5ab-4923-8648-344aaaa0394e:reminderTags │ │ ✔︎ Saved │ reminderTags │ ec915784-2271-4f64-a96d-36b1cf68ae2a:reminderTags │ │ ✔︎ Saved │ reminders │ c9f782ed-1a6d-c9bc-9c91-ee6b1bcfdc88:reminders │ │ ✔︎ Saved │ reminders │ fc2a4739-580e-82ca-8c33-2a63eaf407b7:reminders │ │ ✔︎ Saved │ reminders │ 5fc22aa6-2ac8-cf06-becb-e7e72efb5387:reminders │ │ ✔︎ Saved │ reminders │ a8b9da48-3ba8-1bfe-516d-75e125ff427b:reminders │ │ ✔︎ Saved │ reminders │ 19c9a720-6bc2-48c0-3cf7-3744ab8783f2:reminders │ │ ✔︎ Saved │ reminders │ 4596fa2a-dac6-9fc1-ed13-c74a7194b4ca:reminders │ │ ✔︎ Saved │ reminders │ f374e36f-6c1b-0d6a-5315-3e6f24410dcd:reminders │ │ ✔︎ Saved │ reminders │ 479ead8a-5a2f-a90e-3eff-8e3298c688c0:reminders │ │ ✔︎ Saved │ reminders │ 5eb73cce-8e3e-18f9-f2b6-e30575d454d9:reminders │ │ ✔︎ Saved │ reminders │ b0914fd5-6a7f-44e1-4e83-195aaaf329db:reminders │ │ ✔︎ Saved │ remindersLists │ f02f5728-794b-3aae-5fcd-baec6ac5d58f:remindersLists │ │ ✔︎ Saved │ remindersLists │ bbb7f7eb-4f5b-795c-befa-dfea03aaa1f3:remindersLists │ │ ✔︎ Saved │ remindersLists │ 5db46c0e-6c7e-e9e4-c029-31573f7da70e:remindersLists │ │ ✔︎ Saved │ tags │ ce1be00e-24e5-8289-0e63-376036c5dd78:tags │ │ ✔︎ Saved │ tags │ 521118a5-9b6f-6a79-b28d-67d8991823e5:tags │ │ ✔︎ Saved │ tags │ bb5ea083-a9fd-0736-b388-6511bb464f14:tags │ │ ✔︎ Saved │ tags │ 1d9f0239-a89e-0b3e-6c12-e2896c1972a4:tags │ │ ✔︎ Saved │ tags │ 3fa7f64b-8293-114c-12f5-84d8e9fd4605:tags │ │ ✔︎ Saved │ tags │ 29b8772b-5be6-3c36-3266-f26fb258fae1:tags │ └─────────┴────────────────┴─────────────────────────────────────────────────────┘

6:47

If any of the records we sent were rejected by CloudKit we would get a nicely displayed error in this table, which is something we will show in a moment.

6:59

Let’s now perform an action in the app to see what the logs describe is happening. I am going to navigate to a reminders list and flag a reminder by swiping on its cell. The moment we do that we start getting some logs that ultimately look like this: SQLiteData (private.db) stateUpdate SQLiteData (private.db) willFetchChanges SQLiteData (private.db) stateUpdate SQLiteData (private.db) didFetchChanges SQLiteData (private.db) willSendChanges SQLiteData (private.db) nextRecordZoneChangeBatch ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ event ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ → Sending │ reminders │ 479ead8a-5a2f-a90e-3eff-8e3298c688c0:reminders │ └───────────┴────────────┴────────────────────────────────────────────────┘ SQLiteData (private.db) stateUpdate SQLiteData (private.db) sentRecordZoneChanges ┏━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ✔︎ Saved │ reminders │ 479ead8a-5a2f-a90e-3eff-8e3298c688c0:reminders │ └─────────┴────────────┴────────────────────────────────────────────────┘ SQLiteData (private.db) stateUpdate SQLiteData (private.db) didSendChanges SQLiteData (private.db) stateUpdate

7:21

The stateUpdate happened as soon as we made a change to our database because that immediately caused SQLiteData to enqueue a pending change to the sync engine. After a bit of time passed the sync engine decided to batch together all pending changes, of which there was only one, and send it off to CloudKit. And then a moment later CloudKit let us know everything saved correctly.

8:34

And of course not only is every single change to a database row sync’d to CloudKit, but deletions are too. If we swipe-to-delete a reminder, we will see that after a few moments the following logs are printed: SQLiteData (private.db) stateUpdate SQLiteData (private.db) willFetchChanges SQLiteData (private.db) stateUpdate SQLiteData (private.db) didFetchChanges SQLiteData (private.db) willSendChanges SQLiteData (private.db) stateUpdate SQLiteData (private.db) sentRecordZoneChanges ┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordName ┃ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ⌫ Deleted │ 91974ac5-b5ab-4923-8648-344aaaa0394e:reminderTags │ │ ⌫ Deleted │ 479ead8a-5a2f-a90e-3eff-8e3298c688c0:reminders │ └───────────┴───────────────────────────────────────────────────┘ SQLiteData (private.db) stateUpdate SQLiteData (private.db) didSendChanges SQLiteData (private.db) stateUpdate

9:17

Not only was our reminder deleted, but apparently there was an associated reminder tag too, and that was automatically deleted thanks to the ON DELETE CASCADE clause we have on our foreign key: CREATE TABLE "reminderTags" ( "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" INTEGER NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE ) STRICT

9:49

This says that when the parent row is deleted, which is the reminder, that deletion should cascade down to the associated reminder tag.

10:20

And I really do promise you that all of these changes are being synchronized to CloudKit. To prove this, I am going to delete the app, reinstall, and after a few seconds you will see all of the reminders lists and reminders pop back into view. And if I installed this on my phone, all of these reminders would immediately appear over there too.

12:27

In fact, let’s show that off too. I’ve got my phone connected right now, and I’m going to install to it, and we will get the device’s screen showing here…

12:41

This is the first time I have ever installed the app on this device, and so there is no data at first. But amazingly, we will see that after a moment the lists and reminders start flying in.

13:04

It’s pretty incredible how this is all working and we have not needed to implement new logic in our application. Sure we had to do a lengthy migration process to convert integer IDs to UUIDs, but that’s only done a single time.

13:16

And if I create a new reminder on the simulator, we will see that a moment later that reminder shows up on my device. I can even perform a search on my device, then add a new reminder on the simulator that satisfies that search term, and a moment later the reminder will appear in the search. The app is observing all changes to the database and making sure that the view always shows the most up-to-date information.

14:40

However, what won’t work is if I create a reminder on my device, the simulator does not refresh to show that data. Unfortunately simulators do not get remote notifications, which is the mechanism used by CloudKit to notify the app when new data has appeared in the remote database. Sometimes due to periodic polling of the sync engine it will pull in changes on its own, and sometimes you can kick it in the butt by closing and re-opening the app. But a foolproof way to always get the data to refresh is to simply reinstall the app.

16:08

Also while we’re here let’s show off what happens if I decide to log out of my iCloud account. Because all of the data we have created is so closely associated to the current user logged into iCloud, it is probably not appropriate to keep the data around when we detect a log out.

16:26

So, I’m going to hop over to settings, log out of iCloud, hop back to the app, and we will see all the data magically disappeared. And then if I hop back to settings, log back into iCloud, and hop back to the app, the data magically comes right back. Conflict resolution

17:49

And it may not always be appropriate to clear user data when iCloud accounts change. In the future we may make this customizable so that you, the application developer, can decide what happens when a user logs in or out. You may even want to prompt your user to ask them what they want to do. Stephen

18:06

Now that we’ve got the app running on two devices we can start showing off some of the superpowers of the sync engine. I mean, seamless synchronization across all devices may sound like a superpower already, but things get even better.

18:17

Let’s show off what happens when both devices edit the same record at the same time. That gives an opportunity to show how the library seamlessly handles conflict resolution.

18:27

I’ve the app running on both the simulator and my phone. Just to show that these two devices are communicating with each other over CloudKit, I am going to drill into the “Personal” list on the simulator and add a reminder for “Get car washed”. A moment later we will see that same reminder pop up on my phone.

18:54

Now let’s open the details for this reminder on both devices, and then on the simulator I will add a due date for next week, and on the device I will add a note to “Get winter protection” since winter is coming and I am going flag it.

19:13

I am going to hit save on both devices at roughly the same time…

19:19

…and if we wait a moment we will see that on my phone I now have a “Get car washed” reminder due on 10/8/2025 at 10:35AM with the note “Get winter protection” and it is flagged. All changes were merged together.

19:30

If we look at the logs to see what happened we will see something very interesting: SQLiteData (private.db) nextRecordZoneChangeBatch: scheduled ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ event ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ → Sending │ reminders │ 5b490d7c-9c4c-4a83-9be7-ae35677c2397:reminders │ └───────────┴────────────┴────────────────────────────────────────────────┘ SQLiteData (private.db) stateUpdate SQLiteData (private.db) sentRecordZoneChanges ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ error ┃ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ✘ Save failed │ reminders │ 5b490d7c-9c4c-4a83-9be7-ae35677c2397:reminders │ serverRecordChanged (14) │ └───────────────┴────────────┴────────────────────────────────────────────────┴──────────────────────────┘

19:41

Recall that the nextRecordZoneChangeBatch event is what happens when the sync engine batches together all pending changes to send off to CloudKit, and this event is telling us it tried sending a single reminder, which is the one we edited.

19:52

But then a moment later we get the sentRecordZoneChanges event, which is when CloudKit responds to us to let us know if sending records to it was successful or not. It’s possible for some records to successfully save while others can fail, and here we got a failure. We even print the name of the error: serverRecordChanged (14)

20:10

…which comes from CKError.Code , and enum with many cases of all kinds of errors that can occur.

20:16

The error we got is: case serverRecordChanged An error that occurs when CloudKit rejects a record because the server’s version is different.

20:22

And the 14 is the numeric value of this enum case, which comes from the fact that the enum is from the Objective-C world and so is backed by an integer.

20:31

This error occurs when we try sending a record to CloudKit when it already has a newer version of the record. In particular, it seems that my phone sent its record to iCloud first, it was accepted, and then a moment later the simulator sent its record to iCloud, and that record was rejected. The mechanism that CloudKit uses to determine if a record is “new” or not is very simple. There’s a property on CKRecord , which is the thing that is actually traffic’d back and forth to iCloud, called recordChangeTag . It’s just a little hash string, and if iCloud receives a record with a different recordChangeTag than what it has in its database, it rejects the save.

21:04

But that’s OK. In fact, it’s a good thing iCloud does this, because otherwise the edits the simulator made would have trampled over the edits I made on my phone and we would be left with a reminder that did not have the notes or due date change.

21:12

Our sync engine specifically listens for this error, and then it takes the fresh record that the server has, and the freshest record that the local device has, and merges them. It takes the edits from both records on a per-field basis, taking the field that was edited most recently.

21:28

We can see this in the logs because a moment later the simulator tried sending the same reminder again, but this time it was accepted by CloudKit: SQLiteData (private.db) nextRecordZoneChangeBatch ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ event ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ → Sending │ reminders │ 5b490d7c-9c4c-4a83-9be7-ae35677c2397:reminders │ └───────────┴────────────┴────────────────────────────────────────────────┘ SQLiteData (private.db) stateUpdate SQLiteData (private.db) sentRecordZoneChanges ┏━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ✔︎ Saved │ reminders │ 5b490d7c-9c4c-4a83-9be7-ae35677c2397:reminders │ └─────────┴────────────┴────────────────────────────────────────────────┘

21:39

And that is why at the end of this whole process we get a reminder that has changes from both devices merged together. And if we reinstall the app in the simulator we will see the freshest reminder has loaded. “Get car washed”, “Get winter protection”, due on 10/8 at 10:35am, and flagged.

22:03

Again it’s absolutely incredible that we get this granular form of conflict resolution for free without us doing anything to our app. Outside of migrating our SQLite tables to UUID primary keys and instantiating the SyncEngine , we have yet to make a single change to our codebase. We are getting all of this behavior for free from the sync engine.

22:20

The sync engine also gracefully handles other situations that can arise. For example, I’m going to delete the reminder from my phone and then a moment later I will save an edit to the same reminder on my simulator. Once everything syncs the reminder reappears on my phone with the latest data from the simulator.

22:51

And if we check the logs we will see a story of what happened: SQLiteData (private.db) nextRecordZoneChangeBatch ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ event ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ → Sending │ reminders │ 5b490d7c-9c4c-4a83-9be7-ae35677c2397:reminders │ └───────────┴────────────┴────────────────────────────────────────────────┘ SQLiteData (private.db) stateUpdate SQLiteData (private.db) stateUpdate SQLiteData (private.db) willFetchChanges SQLiteData (private.db) sentRecordZoneChanges ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ error ┃ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩ │ ✘ Save failed │ reminders │ 5b490d7c-9c4c-4a83-9be7-ae35677c2397:reminders │ unknownItem (11) │ └───────────────┴────────────┴────────────────────────────────────────────────┴──────────────────┘

22:55

My simulator tried sending the record to CloudKit, but it was rejected with an unknownItem error, which mean’s CloudKit does not have this record in its database. Now, you may wonder, why did CloudKit throw an error instead of just saving it from scratch? Well, this is thanks to the wonders of the recordChangeTag we mentioned a moment ago. CloudKit sees that the record we are saving has a recordChangeTag , hence it must be an existing record that was previously saved, but it no longer exists in the database because it was deleted, and so it was correct to reject the save.

23:01

Our sync engine gracefully handles this by re-sending the reminder to the server, which causes it to re-synchronize back to my device.

23:36

It’s worth mentioning that we don’t have any fancy conflict resolution, such as employing “ conflict-free replicated data types ”, also known as CRDTs. We aren’t opposed to that, and it may be something we explore in the future, but we think the last-edit-wins strategy on a per-field basis can serve a large number of apps just fine, and so that is what we wanted to perfect first. Drifting schema versions

23:55

We have now seen how the library handles conflict resolution. If multiple devices edit the same record, then the changes will be merged using a “last edit wins” strategy, but it will apply that rule for each field of the record. So, if the two devices edit completely different fields, like say device A edits the title and device B completes the reminder, then those changes will be seamlessly merged. The only time data can be potentially lost is if both devices edit the same field of the same record at the same time. In that case, the most recent edit will overwrite the older edit.

24:26

It’s worth noting how SwiftData handles conflicts. It performs a more simplified version of conflict resolution where it does a “last edit wins” for the entire record. That means if you edit multiple fields of a record on two devices, then whoever hits save last will overwrite all fields on the other device. That is actually the approach we originally took with conflict resolution, but we decided it was worth the effort to handle conflicts on a per-field basis, and that led us down a long, dark rabbit hole that took us a few weeks to get out of! Brandon

24:54

OK, let’s keep going by showing off more powerful features of the sync engine. A huge complication one has to contend with once your app’s data schema is spread across many devices is: what happens when two different devices are running two different schemas? For example, if one device is running a newer version of the app that has some new tables or new fields, and another device is running an older version of the app without any of that data.

25:23

If you approach this problem naively you run the risk of the older app trampling over the data in the newer app since it isn’t aware of that data. Well, luckily for our viewers, this is yet another very thorny problem that we grappled with for many weeks, and ultimately came to a very nice solution. And the best part is that you, the library user, does not have to worry about anything. You can simply migrate your database to add new tables and columns and implement your features as you normally would, but behind the scenes a bunch of work is being performed to make sure that all versions of your app can communicate with each other.

26:00

Let’s explore this by adding a new feature to our app.

26:07

We are going to start by adding a very simple feature to our reminders app. The official one from Apple has the ability to associate a URL with a reminder, and then in the list of reminders that URL is tappable. This means we just need to add one single column to our existing reminders table.

26:26

However, before writing any code, let’s check out the documentation for SQLiteData. As we’ve seen, and as the documentation tries to explain, distributing your schema across many devices is a big decision to make and it means you have to be much more careful with data than you have to be when data is just stored locally.

26:46

Luckily the docs have a section specifically about migrations . And specifically there is information about adding a new table to a schema, adding a column to a schema, and then a list of migrations that are just flat out disallowed. For adding a table, there really aren’t many restrictions, other than the fact that the table must have a primary key and it must be a unique global identifier.

27:08

For adding a column there are some restrictions. Essentially, the column must either be nullable, or it must have a default value with an ON CONFLICT REPLACE clause. This is because if a device on an old version of your app saves a record it will not have the data for the newly added column. And when that record is sync’d to a device that is running the new version of the app, it will try to insert the data into the database without that column. The only way that can work is if the database is allowed to insert

NULL 27:54

It’s worth mentioning that SwiftData has this restriction too, though it takes things further and requires optional fields or default values for every column even on a brand new table, where such restrictions shouldn’t be necessary. The only reason we can think can come up with for why they made this decision is due to SwiftData’s reliance on implicit migrations.

NULL 28:29

SwiftData does not have access to the discrete units of migration like we have in our schema, where we can directly say we are creating a table versus adding a column. Instead, SwiftData only sees the change in schema from one run of the app to the next run, and it has no idea if you are just adding a new column during your development cycle of working on a feature, or if this is a brand new column relative to a schema that’s already been deployed to your users and running on their devices. And so it has no choice but to take a very conservative approach with migrations and simply require that all data be optional or have a default. And the fact of the matter is, it’s not always appropriate to make fields optional or have a default.

NULL 29:14

But let’s get back on track. Because this is a brand new column being added to a table that already exists on our user’s devices, we must make it optional or provide a default. In the case of a URL, making it optional probably makes the most sense, so let’s update our Reminder Swift data type: @Table struct Reminder: Identifiable { … var url: URL? … }

NULL 29:54

And as always, any changes we make to our Swift data types must have an accompanying migration where we alter the SQLite table: migrator.registerMigration("Add URL to reminders") { db in try #sql( """ ALTER TABLE "reminders" ADD COLUMN "url" TEXT """ ) .execute(db) }

NULL 30:45

Because we did not add a NOT NULL constraint to this column there is no need to add ON CONFLICT REPLACE . That is only needed when the column is non-null and has a default.

NULL 31:09

That’s all it takes to update the schema of our app. We can even update some of the seed data we use in previews and tests to add a URL. For example, our “Buy concert tickets” reminder: Reminder( id: UUID(5), dueDate: now, remindersListID: UUID(1), title: "Buy concert tickets", url: URL(string: "https://www.buytix.com") )

NULL 31:32

And it only takes a little bit of work to start showing this data in the reminder row when it’s available. After the subtitle of at the bottom of the row we can check if we have a URL for the reminder, and if so put it in a button with a rounded gray rectangle behind it: if let url = reminder.url { Button { } label: { Text(url.host(percentEncoded: true) ?? "Link") .padding(8) .foregroundStyle(Color.black) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.init(white: 0, opacity: 0.1)) ) } }

NULL 32:29

We can even use the openURL environment: @Environment(\.openURL) var openURL …to open the URL when the button is tapped: Button { openURL(url) } label: { … }

NULL 32:45

With that done we can see the URL button appearing in the list. But now we have to make it possible to assign a URL to a reminder.

NULL 33:00

This can be done by adding a text field to the ReminderFormView , which we will do right below the notes: TextField("URL", text: <#???#>)

NULL 33:13

But how can we derive a binding to the URL? Text fields require a binding to an honest string, and we only have an optional URL.

NULL 33:20

Well, we can do something we’ve done a few times in this file to coerce some state that does not play nicely with SwiftUI bindings into something that does. We can add a computed property to optional URLs with a get and a set : extension URL? { fileprivate var absoluteString: String { get { self?.absoluteString ?? "" } set { self = URL(string: newValue) } } }

NULL 33:58

And then we can use dynamic member lookup to chain into this property on our optional URL: TextField("URL", text: $reminder.url.absoluteString)

NULL 34:12

And that right there will be a binding of a non-optional string. And we can add a few modifiers to make it a little nicer to type text into this field: TextField("URL", text: $reminder.url.absoluteString) .keyboardType(.URL) .textInputAutocapitalization(.never) .disableAutocorrection(true)

NULL 34:36

That is all it takes, and we now have the feature implemented in our app. Let give it a spin in the simulator. I am going to recreate the “Walk the dog” reminder, and I am going to add a URL pointing to my dog walker’s web site…

NULL 34:58

After I hit save I see the button in the list of reminders, and I can tap that button to hop to Safari.

NULL 35:09

Looking at the logs: SQLiteData (private.db) nextRecordZoneChangeBatch ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ event ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ → Sending │ reminders │ 7a93d9c4-d7e5-4c44-9a12-e2de73663748:reminders │ └───────────┴────────────┴────────────────────────────────────────────────┘ SQLiteData (private.db) stateUpdate SQLiteData (private.db) willFetchChanges SQLiteData (private.db) stateUpdate SQLiteData (private.db) didFetchChanges SQLiteData (private.db) willSendChanges SQLiteData (private.db) sentRecordZoneChanges ┏━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ✔︎ Saved │ reminders │ 7a93d9c4-d7e5-4c44-9a12-e2de73663748:reminders │ └─────────┴────────────┴────────────────────────────────────────────────┘ …it seems like everything worked just fine. A batch of changes was prepared and sent to CloudKit, and CloudKit accepted those changes. And let’s take a note of this UUID because it will be important in a moment. It begins with “7a93”.

NULL 35:26

But the big question is, what happens if I run our app on my phone without building the newest version. I am specifically going to run the version of the app that does not have the URL feature we just added. I’ll do this by stashing all the changes we just made in git, and then running the device on my phone.

NULL 36:05

After a moment the “Walk the dog” reminder appears on my device, and a look at the logs shows that indeed we did get this change from CloudKit, and it does match the “7a93” ID: SQLiteData (private.db) fetchedRecordZoneChanges ┏━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ✔︎ Modified │ reminders │ 7a93d9c4-d7e5-4c44-9a12-e2de73663748:reminders │ └────────────┴────────────┴────────────────────────────────────────────────┘

NULL 36:10

But we can see that the URL is not being shown in the list of reminders, and if I open the reminder we will see there is no URL field for us to enter. Does that mean the data has been deleted?

NULL 36:29

Well, the data seems to still be visible on the simulator just fine. Further, what if I were to make a change to this record by, say, adding a note. If I hit “Save” we will see in the logs that the record was sent to CloudKit and CloudKit accepted the record. SQLiteData (private.db) nextRecordZoneChangeBatch ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ event ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ → Sending │ reminders │ 7a93d9c4-d7e5-4c44-9a12-e2de73663748:reminders │ └───────────┴────────────┴────────────────────────────────────────────────┘ SQLiteData (private.db) stateUpdate SQLiteData (private.db) sentRecordZoneChanges ┏━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ✔︎ Saved │ reminders │ 7a93d9c4-d7e5-4c44-9a12-e2de73663748:reminders │ └─────────┴────────────┴────────────────────────────────────────────────┘

NULL 36:54

But surely this means we have lost the URL now, right? A device that has no idea that URLs are associated with reminders has saved the record, and so does that mean the URL has now been accidentally cleared out?

NULL 37:10

Well, if we go back to the simulator we will amazingly see the due date pop in, just as it was edited on my phone, but the URL survived. So, even though a device that has an old version of the schema made changes to a record that was created by a device with the new version of the schema, we did not have any data loss.

NULL 37:55

And of course if we go the other way, such as creating a reminder on my phone, which has the old schema, that data will still sync to devices with the new schema, thanks to the fact that we marked the new field as optional. So, if on my phone I create “Walk the dog”, then on the simulator eventually that reminder will arrive. Next time: Assets

NULL 41:00

We have now seen that the SyncEngine perfectly handles the situation where multiple apps are running different schemas. A device running the newest version of the app can create and edit records, and that data will be synchronized to devices running an old version of the app, even if those versions have no idea how to handle the data being sent to it.

NULL 41:20

It’s amazing to see that SQLiteData takes special care of your data to make sure that apps running different versions of your schema can communicate with each other without losing data. And best of all, it’s completely seamless. You don’t have to think about it as a user of SQLiteData. You can just rest assured that your user’s data is synchronized across all of their devices, regardless of what version of your app they are running. Stephen

NULL 41:42

But let’s push this further. Let’s add a feature that requires a whole new table. In some ways this kind of schema drift seems simpler because we don’t have to worry about apps with different versions of the schema editing the same record.

NULL 41:54

But it is still subtly tricky. When the sync engine on the device with the old schema receives records for tables it doesn’t recognize, it can’t just silently discard that data. The sync engine will only receive that data once. It won’t magically get the data again once the app is updated and is running on the new schema. And so it has to take extra care to save that data for later so that when the app is upgraded and has access to the new tables, it can retry inserting all that data into the tables.

NULL 42:20

To explore this we are going to add a feature to our app that allows associating a cover image to reminders lists as a way of personalizing things a bit. This will also give us a chance to show off how the sync engine automatically handles assets for us behind the scenes without us having to think about it.

NULL 42:35

Let’s get started…next time! References SQLiteData Brandon Williams & Stephen Celis A fast, lightweight replacement for SwiftData, powered by SQL. https://github.com/pointfreeco/sqlite-data StructuredQueries A library for building SQL in a safe, expressive, and composable manner. https://github.com/pointfreeco/swift-structured-queries Conflict-free replicated data type (CRDT) In distributed computing, a conflict-free replicated data type (CRDT) is a data structure that is replicated across multiple computers in a network. https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type Downloads Sample code 0341-sync-pt2 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .