EP 342 · CloudKit Sync · Oct 20, 2025 ·Members

Video #342: CloudKit Sync: Assets

smart_display

Loading stream…

Video #342: CloudKit Sync: Assets

Episode: Video #342 Date: Oct 20, 2025 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep342-cloudkit-sync-assets

Episode thumbnail

Description

We introduce a new feature to our reminders app: cover images for each reminders list. This pushes us to create a brand new database table to synchronize, and allows us to demonstrate how SQLiteData seamlessly handles binary blobs by converting them to CloudKit assets under the hood.

Video

Cloudflare Stream video ID: 87043221cd018d1a0b49d523f757ab74 Local file: video_342_cloudkit-sync-assets.mp4 *(download with --video 342)*

References

Transcript

0:05

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.

0:25

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

0:48

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.

0:59

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.

1:26

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.

1:40

Let’s get started New tables and assets

1:45

Let’s start with the domain modeling problem. How should we represent an image in our database. The easiest way to do this is to add a new field to the RemindersList data that is just some optional data: @Table struct RemindersList { … let coverImage: Data? … }

1:59

If the list has a cover image, it will hold onto just some collection of bytes that represents the image. We could then turn those bytes into an a UIImage which can then be put into a SwiftUI Image view.

2:09

There’s a few things to be aware of with doing something like this. First, CloudKit has limitations of how large its records can be. This is even mentioned in the docs . Important To ensure the speed of fetching and saving records, the data that a record stores must not exceed 1 MB. Assets don’t count toward this limit, but all other data types do.

2:27

However, our library takes care of this transparently for you. All data blobs are automatically converted to CKAsset s, which as mentioned in the warning, do not count towards the record limit. So you don’t have worry about how large your data gets.

2:39

However, even though you don’t have to worry about data size, there is a reason you may not want to model your domain exactly like this. SQLite does have really fantastic support for binary blobs in tables. For very small binary blobs SQLite is able to store the data more efficiently than most file systems can even store the data as individual files. And for moderately sized binary blobs, up to about 1 megabyte or so, accessing the data from the SQLite is faster than reading from the filesystem itself.

3:05

All of that speaks to just how performant and well-designed SQLite is, but also that’s just for storing and reading the data. There’s another aspect to SQLite databases, which is querying. Scanning over tables that have large binary blob columns can be slower than if those data blobs were not there at all. And you may accidentally load a bunch of data in memory that you don’t expect.

3:24

For example, in the home view of our app where we display all lists at once, we do not play on showing the cover images there at all. Yet if we did a seemingly innocent query like this: @FetchAll(RemindersList.all) var remindersLists …we would be inadvertently loading the image data into memory even though we are not going to use it.

3:45

And so that is why a common recommendation for storing blobs in SQLite is to move that data to a separate table that has a foreign key pointing to the table that wants the data. That keeps queries on the reminders list super fast and we will only load image data if we explicitly join this secondary table.

3:59

So, let’s define a new Swift type to represent an asset associated with a reminders list: @Table struct RemindersListAsset { }

4:08

This will hold the data for the image as a non-optional value: @Table struct RemindersListAsset { var coverImage: Data }

4:13

We are allowed to use a non-optional value because this is a brand new table. No need to make it optional or give it a default like one must do in SwiftData.

4:20

This table needs a primary key and it needs a foreign key pointing to the reminders list it belongs to, so we technically can do this: @Table struct RemindersListAsset: Identifiable { let id: UUID var coverImage: Data let remindersListID: RemindersList.ID }

4:37

But this allows the association of many assets to a single reminders list. We would prefer if a reminders list could have either no asset or a single asset associated with it.

4:45

To do this we can employ a trick by making the foreign key serve as the primary key at the same time: @Table struct RemindersListAsset: Identifiable { @Column(primaryKey: true) let remindersListID: RemindersList.ID var coverImage: Data }

5:02

This enforces that at most one asset can be associated with a reminders list because primary keys must be unique. But now we do have to specify an id property for the Identifiable conformance: var id: RemindersList.ID { remindersListID }

5:18

OK, that’s all we need from the Swift data type.

5:21

And it is worth mentioning that there are definitely certain schemas out there for which holding onto data blobs directly in a SQLite table is not appropriate. For example, if you are making a photos app, then storing the photos in SQLite is probably going to be more pain than it’s worth. Or if you are building a music app, then storing MP3s in SQLite is also going to probably be a pain. However, for a contacts app that associates images with with a person, it’s probably totally fine to hold that image data directly in SQLite. Even a podcasts app could probably store its MP3s directly in SQLite because it’s probably not necessary to store many gigabytes of that data at once.

5:50

With that said, our use case here is perfect for storing data directly in SQLite.

5:54

Moving on, anytime you add a new table to your SQLite schema you should immediately go over to the SyncEngine to add it to the list of tables that participate in synchronization: $0.defaultSyncEngine = try! SyncEngine( for: $0.defaultDatabase, tables: Reminder.self, RemindersList.self, Tag.self, ReminderTag.self, RemindersListAsset.self, )

6:12

We are sure that the #1 problem people are going to have with our CloudKit synchronization tools is forgetting to update this list. And unfortunately it is not appropriate for us to simply inspect your database and synchronize all tables in your schema. First off all, there are some secret tables that SQLite uses that we would need to avoid. And even GRDB uses a special secret table to manage its migrations, so we would have to avoid that too. And further, even some of your own tables are not appropriate to sync, such as the FTS5 search index. That data should be local to the device and not sent to CloudKit.

6:44

And this is also how SwiftData works. When creating a ModelContainer you must list the types of the models you are using. So make sure to keep the SyncEngine up-to-date with your Swift data types.

6:55

Next we need to add a migration to our database bootstrap in order to create the SQLite table that represents this data type. So, let’s register a new migration: migrator.registerMigration("Create RemindersListAsset table") { db in }

7:16

In this migration we will create a new table from scratch using plain ole SQL. As we’ve mentioned many times in our episodes, we feel that plain SQL strings is the most robust way to create SQLite tables.

7:25

And something new we are going to do with this table definition that we have never done before, is when defining the “remindersListID” column, we will both make it a primary key and a foreign key: try #sql( """ CREATE TABLE "remindersListAssets" ( "remindersListID" TEXT NOT NULL PRIMARY KEY REFERENCES "remindersLists"("id") ON DELETE CASCADE, "coverImage" BLOB NOT NULL ) STRICT """ ) .execute(db)

8:15

And crucially we are not using the ON CONFLICT REPLACE DEFAULT clause with this because it would never be appropriate to generate a new random ID for an asset. Its primary key ID should always point to an existing reminders list, and so it must be explicitly supplied when creating rows.

8:30

OK, that is all it takes to get our Swift data type and SQLite table into place. Now let’s actually build the feature. Let’s start by making the changes to the RemindersListForm that will allow us to associate an image with a reminders list. We’ll start by adding some state to our form view to handle this. Right now this feature is built in a way where all state and logic is directly in the view rather than an observable model, and so we will continue that pattern.

8:44

First we will hold onto some optional data that will represent the current cover image for the list: @State var coverImageData: Data?

8:51

Note that we are not going to hold onto an actual UIImage in our view and instead do that conversion as we need it. This is because if we eagerly convert image data to a UIImage without keeping around the data, then we will have to eventually convert it back to data and that comes with some complications. So we will side step all of that by just holding onto the data.

9:08

Then we will have some state for whether or not a photo picker is presented, which is what we will use for the user to select a photo: @State var isPhotoPickerPresented = false

9:15

And then due to the way photo pickers work in SwiftUI we will also hold onto some PhotosPickerItem state, which is what the picker view writes to when a user chooses an item: @State var photosPickerItem: PhotosPickerItem?

9:24

But to get access to that symbol we need to import PhotosUI: import PhotosUI

9:29

Next, when the form first appears, we will make a database request to load the current image data for the list: .task { guard let remindersListID = remindersList.id else { return } await withErrorReporting { coverImageData = try await database.read { db in try RemindersListAsset .where { $0.remindersListID.eq(remindersListID) } .select(\.coverImage) .fetchOne(db) } } }

10:48

And this right here shows us why it was important to move the image data to its own table. This form view needs this data, and so it explicitly makes a database call to get the data. Whereas the view of reminders lists at the root of the app does not need this image data, and will naturally never load it since it doesn’t join to the RemindersListAsset table.

11:05

Next we are going to add the image view to the form so that it displays the view if it’s populated, and will show a button for selecting a cover image, as well as a clear button to remove the image if it is populated. We aren’t going to spend the time building this from scratch, so I will copy-and-paste it: ZStack(alignment: .topTrailing) { ZStack { if let coverImageData, let coverImage = UIImage(data: coverImageData) { Image(uiImage: coverImage) .resizable() .scaledToFill() .frame(height: 150) .clipped() .cornerRadius(10) } else { Rectangle() .fill(Color.secondary.opacity(0.1)) .frame(height: 150) .cornerRadius(10) } Button("Select Cover Image") { isPhotoPickerPresented = true } .padding() .background(.ultraThinMaterial) .clipShape(.capsule) } if coverImageData != nil { Button { coverImageData = nil } label: { Image(systemName: "xmark.circle.fill") .foregroundColor(.red) .background(Color.white) .clipShape(Circle()) } .padding(8) } } .buttonStyle(.plain) We have some ZStack s for layering these views, and we can see that if there is an image, we display it. If not, we just show a grey rectangle. And if there is an image we also put an “X” button in the top-left which will clear out the image when tapped.

11:31

And already in the preview we see the basics of this view hierarchy displayed. The “Select Cover Image” button does not work because we haven’t hooked up that logic yet. First we can drive the presentation of a photo picker from the isPhotoPickerPresented state we have in the view: .photosPicker( isPresented: $isPhotoPickerPresented, selection: $photosPickerItem )

12:14

And then once the user has chosen an image, the picker view will write to the photosPickerItem state. So, if we listen for a change in that state we can capture the data for that image: .onChange(of: photosPickerItem) { Task { await withErrorReporting { if let photosPickerItem { coverImageData = try await photosPickerItem .loadTransferable(type: Data.self) self.photosPickerItem = nil } } } }

13:10

It probably isn’t appropriate to just take the data straight from the device. After all, the image could be gigantic, possibly multiple megabytes, and for the purpose of our cover image I don’t think we need an ultra high-resolution image. So, let’s paste in a little helper that can resize an image to a max of 1000 pixels and apply a bit of JPEG compression: private func resizedAndOptimizedImageData( from data: Data, maxWidth: CGFloat = 1000 ) -> Data? { guard let image = UIImage(data: data) else { return nil } let originalSize = image.size let scaleFactor = min(1, maxWidth / originalSize.width) let newSize = CGSize( width: originalSize.width * scaleFactor, height: originalSize.height * scaleFactor ) UIGraphicsBeginImageContextWithOptions(newSize, false, 1) image.draw(in: CGRect(origin: .zero, size: newSize)) let resizedImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return resizedImage?.jpegData(compressionQuality: 0.8) }

13:30

And then we can apply that transformation when we get data back from the photo picker: coverImageData = try await photosPickerItem .loadTransferable(type: Data.self) .flatMap { resizedAndOptimizedImageData(from: $0) }

13:45

That’s all it takes to integrate the photo picker, and now we can pick an image from the preview, and it will be populated in the view. And we can tap the “X” to clear it out.

13:58

OK, the final steps to implementing the cover image logic in this form feature is to update our “Save” action so that it takes into account the cover image. Right now it only does an upsert for updating the reminders list: try RemindersList.upsert { remindersList } .execute(db)

14:11

We will beef this up by checking if there is cover image data, and if so we will upsert that data into our table, and otherwise we will delete the asset from the database: if let coverImageData { // Upsert asset } else { // Delete asset }

14:23

Each of these branches is straightforward to implement. For the upsert branch, we almost have everything available to us: try RemindersListAsset.upsert { RemindersListAsset( remindersListID: <#RemindersList.ID#>, coverImage: coverImageData ) } .execute(db)

14:50

The only thing we are missing is the remindersListID . We can’t take this from the draft we hold in state because that ID may be nil . But we did just upsert the reminders list, and so there definitely should be a list in the database at that point, whether it’s a fresh list or a previously existing list.

15:05

There is a special tool we can use to simultaneously upsert the reminders list and get some or all of the data that was just freshly inserted or updated. It’s called returning : .returning( <#(From.TableColumns) -> repeat TableColumn<From, each QueryValue>#> )

15:16

…and it allows us to specify a trailing closure to compute any data from the affect row (or rows) and return it.

15:24

So, let’s fetch the ID for the affected reminders list, and perform a fetchOne to get that single result: let remindersListID = try RemindersList.upsert { remindersList } .returning(\.id) .fetchOne(db)

15:35

We don’t expect this value to ever be nil , so we could force unwrap it, but also there’s no harm in just unwrapping it, which we can do as a guard since everything that comes after this isn’t relevant unless we have a list ID: guard let remindersListID else { return }

15:47

And now we have all the information available to us upsert the asset: try RemindersListAsset.upsert { RemindersListAsset( remindersListID: remindersListID, coverImage: coverImageData ) } .execute(db)

15:58

And the else branch can be implemented by finding the asset associated with that list ID and deleting it: } else { try RemindersListAsset.find(remindersListID).delete().execute(db) }

16:09

And while we can’t yet perform an end-to-end test of this feature, we can at least run it in the preview and inspect the logs to see that the SQL queries we expect are being executed. Finishing off assets

17:18

And that’s what it takes to support cover images in the reminders list form. It’s worth noting that the logic for this feature is starting to get gnarly enough that we may want to consider pulling it out into its own @Observable model so that we could write some tests. We won’t do that now, but luckily our tools work in both SwiftUI views and @Observable models, and so you are free to embark on that refactor if you and your team think it’s a good idea. Brandon

17:40

OK, the final, final step to finishing off this cover image feature is to start displaying it. We will add some new state to the observable RemindersDetailModel that powers the feature that shows a list of reminders. This feature is pretty significant and so that is why we decided to extract its logic and behavior into a dedicated model.

18:00

Let’s take a look.

18:03

Let’s add a @FetchOne piece of state that represents fetching the asset associated with the reminders list being displayed: @ObservationIgnored @FetchOne var remindersListAsset: RemindersListAsset?

18:32

And you may be looking at these two pieces of state sitting side-by-side: @ObservationIgnored @FetchAll var rows: [Row] @ObservationIgnored @FetchOne var remindersListAsset: RemindersListAsset?

18:37

…and wonder if there’s an optimization we could perform by combining both of these queries into a single FetchKeyRequest , as we did for search: struct SearchRequest: FetchKeyRequest { struct Value { var completedCount = 0 var rows: [Row] = [] var tags: [Tag] = [] } let searchText: String let searchTokens: [Token] func fetch(_ db: Database) throws -> Value { … } }

18:53

That allowed us to fetch a bunch of related data in a single database transaction.

19:13

However, this is an example of where we specifically do not want to do this. It is more performant for us to execute these two queries separately. This is because the data for the rows of the list is likely to change a lot more often than the image. In fact, the image will almost never change while you are looking at the list. And so by grouping the queries into a single transaction we will decode image data from SQLite every time the row data changes, even though the image data most likely did not change.

19:55

So, we will keep these two pieces of state separate. Now we just have to load a query into this FetchOne . It can’t be done right at the declaration of the state because it depends on dynamic data that is only known once the observable model has been initialized.

20:16

So, in the initializer of the model we can switch on the detailType since cover images are only relevant to reminders lists, and then initialize the FetchOne with a query: switch detailType { case .remindersList(let remindersList): _remindersListAsset = FetchOne( RemindersListAsset .find(remindersList.id), animation: .default ) default: break }

21:23

However, we have to be careful here. We should also load a query in the default case that represents not running a query at all since it isn’t relevant: default: _remindersListAsset = FetchOne(RemindersListAsset.none) break Otherwise, by default, the @FetchOne will naively just load a single row from the asset table, even though it isn’t correct.

22:06

Better would be to just load a none query into the declaration of FetchOne so that we don’t have to worry about this: @ObservationIgnored @FetchOne(RemindersListAsset.none) var remindersListAsset

22:24

Whenever you see a none query in the declaration of @FetchOne or @FetchAll , you can interpret that as simply wanting to delay any querying until a later time, whether that be in the initializer or from some user action or something completely different.

22:43

Now that we have loaded the data for the cover image we can render it at the top of the list, and we can make it so that it is not given the default insets: var body: some View { List { if let remindersListAsset = model.remindersListAsset, let image = UIImage(data: remindersListAsset.coverImage) { Image(uiImage: image) .resizable() .scaledToFill() .frame(maxHeight: 200) .clipped() .listRowInsets(EdgeInsets()) } … } … }

23:47

We can now give this feature a spin in the simulator by opening up the details of a list, selecting an image, and hitting “Save”. Then if we drill down to that list we will see the image displayed at the top of the list.

24:18

Further, if we look at the logs we will see that both an asset and a list was sent to CloudKit and subsequently accepted by CloudKit: SQLiteData (private.db) nextRecordZoneChangeBatch ┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ event ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ → Sending │ remindersListAssets │ f02f5728-794b-3aae-5fcd-baec6ac5d58f:remindersListAssets │ │ → Sending │ remindersLists │ f02f5728-794b-3aae-5fcd-baec6ac5d58f:remindersLists │ └───────────┴─────────────────────┴──────────────────────────────────────────────────────────┘ SQLiteData (private.db) stateUpdate SQLiteData (private.db) sentRecordZoneChanges ┏━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ✔︎ Saved │ remindersListAssets │ f02f5728-794b-3aae-5fcd-baec6ac5d58f:remindersListAssets │ │ ✔︎ Saved │ remindersLists │ f02f5728-794b-3aae-5fcd-baec6ac5d58f:remindersLists │ └─────────┴─────────────────────┴──────────────────────────────────────────────────────────┘

24:44

And a really cool thing happening here is that the sync engine is synchronizing the remindersListAssets table, but behind the scene it has actually converted the data blob to a proper CKAsset so that it does not bloat the record sent to CloudKit. It’s all handled seamlessly for you so that you don’t even have to think about it.

25:16

And to really prove this is actually synchronizing this asset to CloudKit, let’s delete the app, reinstall, wait a moment, and we will see our lists fly back in. And if we drill down to the “Personal” list we will see that it does have its cover image.

25:50

And so things are working great on this device. But what about the app on my phone that still has the old schema? It doesn’t know anything about cover images for reminders lists. Let’s stash all the changes we have made so far in git so that we can re-run the app on my phone…

27:10

If we open the details for a list we will see there is no ability to select a cover image for it. And if we drill down to the “Personal” list we will see there is no image displayed.

27:12

However, looking at the logs we can see that when the app started up it did fetch information for the asset: SQLiteData (private.db) fetchedRecordZoneChanges ┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ✔︎ Modified │ remindersListAssets │ f02f5728-794b-3aae-5fcd-baec6ac5d58f:remindersListAssets │ │ ✔︎ Modified │ remindersLists │ f02f5728-794b-3aae-5fcd-baec6ac5d58f:remindersLists │ └────────────┴─────────────────────┴──────────────────────────────────────────────────────────┘

27:20

…even though it has no knowledge whatsoever about this data. And it’s a little dangerous that we are receiving this data right now because we will not receive it later unless the data changes on CloudKit. And so when we do finally update the app on this device we run the risk of completely missing out on that data and displaying things in a broken state.

27:41

Well, luckily for us the SyncEngine takes special care here by caching the data for any records it does not understand so that it can try inserting them again later once it does understand the table. To see this, let’s pop the stash in git to bring back the asset changes, and run the app in the simulator. If we navigate to the “Personal” list we will see the cover image.

28:27

And to really prove that it’s actually remarkable that everything is magically working behind the scenes without you thinking about it, let’s take a look at the logs: SQLiteData (shared.db) stateUpdate SQLiteData (private.db) stateUpdate SQLiteData (shared.db) willFetchChanges SQLiteData (private.db) willFetchChanges SQLiteData (shared.db) stateUpdate SQLiteData (private.db) stateUpdate SQLiteData (shared.db) didFetchChanges SQLiteData (private.db) didFetchChanges

28:33

That’s all there is. In particular, there are no tables showing us records that were downloaded for reminders lists or assets. That means the SyncEngine did not receive any extra information from CloudKit in order to backfill the asset. It was able to use only the information it had locally cached to bring our local database up-to-date with what is in CloudKit. It’s really amazing to see.

28:56

Now let’s see that these images really are sync’ing between devices. I am going to change the cover image for this list in the simulator, and after waiting a few seconds we will see that the that iPhone updates to the new image. It even live updated while we had the list opened, and that’s thanks to the @FetchOne we installed in that feature. I can also remove the cover image over on the simulator, and then a moment later we will see the image clears from my phone.

31:00

Assets also participate in the conflict resolution strategy we mentioned last episode. If one device edits the cover image and another device edits the title, then those changes will merge just fine. But if both devices edit the cover image then whichever one edited it most recently will be the one that wins. Stopping and starting the sync engine

32:08

This is just really incredible stuff.

32:10

We have added a feature to our reminders app that I think in usual circumstances we might groan about and dread implementing. We need to be able to associated an image with each reminders list, have it persist to disk, and have it magically synchronize to all devices.

32:26

But thanks to all of the smarts we have baked into SQLiteData it was a cinch! We simply added a new SQLite table to hold onto some data for an image, we added that table to our SyncEngine , and then we implemented the feature so that the user could choose an image for their list. Once that was done everything just worked how we would hope. As soon as the user saved their reminders list with a cover image chosen, it was magically synchronized to all other devices.

32:52

And each step of the way SQLiteData is handling conflict resolution, packaging up binary blobs into assets so that we stay within the limits of CloudKit, and handling dozens upon dozens of other edge cases that can crop up in data synchronization. For example, due to the distributed nature of CloudKit it is theoretically possible that when one device creates a reminders list with asset, that another device will first receive the asset and then a moment later receive the reminders list. If we were to naively try to insert the asset into the database before receiving the list we would cause a foreign key constraint failure in SQLite, and that data would be gone. But luckily for us SQLiteData takes great care to make all of that works just as you would hope. Stephen

33:44

OK, let’s now show off a feature in the sync engine that came as a direct result of feedback we got from developers during the beta period of the tools. Multiple people mentioned that it’s not appropriate to always sync the user’s data. Sometimes it needs to be a setting that the user can opt into or out of, and other times people want to gate synchronization as a “pro” feature that requires an in-app purchase.

34:05

Whatever the reasons, we did feel that being able to stop and start the sync engine was compelling enough for us to implement it as a feature!

34:11

Let’s take a quick look.

34:15

At the entry point of the app, where we create the sync engine, there is an additional parameter we can provide called startImmediately : $0.defaultSyncEngine = try! SyncEngine( … startImmediately: <#T##Bool#> )

34:24

The docs describe the function of this argument: startImmediately Determines if the sync engine starts right away or requires an explicit call to start() . By default this argument is true .

34:35

So this allows us to start our app in a state where the sync engine does not actually synchronize anything. It won’t send data to CloudKit and it won’t listen for changes in the external CloudKit database either. So to play around with this feature let’s start our app with sync’ing off: $0.defaultSyncEngine = try! SyncEngine( … startImmediately: false )

34:49

There is a related method start , which then gets the sync engine synchronizing again, and also a method stop() that stops the sync engine: /// Starts the sync engine if it is stopped. /// /// When a sync engine is started it will upload all data stored /// locally that has not yet been synchronized to CloudKit, and will /// download all changes from CloudKit since the last time it /// synchronized. /// /// > Note: By default, sync engines start syncing when initialized. public func start() async throws { … } /// Stops the sync engine if it is running. /// /// All edits made after stopping the sync engine will not be /// synchronized to CloudKit. You must start the sync engine again /// using start() to synchronize the changes. public func stop() { … }

35:02

The docs also detail how this mechanism works. In particular, any edits made while the sync engine is stopped will uploaded to CloudKit as soon as the sync engine is started again. So again you just don’t have to think about the gnarly details of how data saved while offline is going to find its way back to CloudKit once connected.

35:18

Let’s explore this by adding a feature to our app that allows the user to manually turn synchronizing off and on. We are going to do this by adding a button to the top-left of the root view, which already has a button in debug builds for seeding the database.

35:30

To accommodate both actions we will put the seed button in a Menu : ToolbarItem(placement: .primaryAction) { Menu { #if DEBUG Button("Seed") { @Dependency(\.defaultDatabase) var database try! database.write { db in try seedDatabase(db) } } #endif } label: { Image(systemName: "ellipsis.circle") } }

35:52

And then in this menu we will add a new button for stopping the sync functionality: Button { } label: { Text("Stop synchronizing") }

36:01

And in fact, the sync engine has some state in it that lets you know if the sync engine is currently running or not: /// Determines if the sync engine is currently running or not. public var isRunning: Bool { … }

36:14

However, to get access to this state we of course need access to the sync engine itself. But so far the only time we have referenced the sync engine is when preparing the database at the entry point of the app. This is the first time we’ve needed the sync engine elsewhere.

36:26

And it’s quite easy to get access to the sync engine. We just use the @Dependency property wrapper, and we can even use it directly in the view: @Dependency(\.defaultSyncEngine) var syncEngine

36:42

We can also use this property wrapper in @Observable models if we wish, and even in UIKit controllers, AppKit controllers, and more.

36:49

Now we can ask if the sync engine is running in order to display a stop or start button: Text("\(syncEngine.isRunning ? "Stop" : "Start") synchronizing")

37:06

A fun fact about the isRunning property is that it plays nicely with Swift’s observation tools. This means that if you access the property in a view, or any observable context, when it changes the view will automatically re-render.

37:17

Then, when the button is tapped we can check if the sync engine is running, and if so we stop it, and otherwise we start it. But the act of starting the sync engine is async, and so we have to spin up an unstructured task to perform that work: Button { if syncEngine.isRunning { syncEngine.stop() } else { Task { try await syncEngine.start() } } } label: { Text("\(syncEngine.isRunning ? "Stop" : "Start") synchronizing") }

37:37

And that’s it really. Let’s run the app, turn off synchronization, and then make some changes to the database, such as adding a reminder “Read a book”, and “Call Blob”. And let’s delete a reminder.

38:13

If we check out the logs, specifically for “SQLiteData”, we will find complete radio silence. No logs from the sync engine about sending data to CloudKit or receiving data from CloudKit. I can even add some reminders on my phone, say adding a reminder “Clean house”, wait for it to send its data to CloudKit, and then relaunch the simulator to see again, no logs. The sync engine just isn’t doing anything at all.

38:25

But, if we go back to the root, turn synchronization on, not only do our changes get sent out to CloudKit, but the changes we made on the phone are synchronized to the simulator. This feature of the sync engine is a nice way to emulate offline mode for your app. You can simply turn off the sync engine, perform a bunch of actions, and then turn it back on to see that everything is synchronized correctly.

39:01

Even conflict resolution works correctly. For example, let’s turn synchronization off in the simulator. And then let’s update some reminders, such as editing the “Walk the dog” reminder to add a URL for our dog walker company, “www.blobwalksdogs.com”. Then, while the simulator is still offline, over on the phone we are going to complete the “Walk the dog” reminder.

39:59

Now let’s turn sync back on in the simulator, wait a moment for data to synchronize, and we will find that the “Walk a dog” reminder disappears. And if we show completed reminders in this list we will find the “Walk a dog” reminder, with the URL, and its completed. Next time: iCloud sharing

40:23

OK, we have shown an incredible amount of power in our SQLiteData library. Not only does it give us seamless synchronization of our SQLite database to CloudKit, and not only does it automatically handle conflict resolution without you having to think about it, and not only does it handle large binary assets in the background, but it is also easy to turn off and on so that you can be very precise about when user data is synchronized. Brandon

40:48

But can you believe it gets better? What if we said that with just a few more lines of code we can make it possible for our users to share a record with another iCloud user so that you two can collaborate? And further, all associations will automatically be shared. And we even have full support for permissions so that you can grant read-only access or read-write access.

41:13

And on top of all of that, we make all the underlying CloudKit metadata 100% publicly available so that you are free to grab that information, read from it, and do whatever you want with it. It is even accessible to you at the SQL querying level, which means you can write complex SQL queries that interact with CloudKit information.

41:35

It’s incredible to see, and we’re going to show it all off, but first let’s get the basics of sharing into place…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 Downloads Sample code 0342-sync-pt3 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 .