EP 344 · CloudKit Sync · Nov 3, 2025 ·Members

Video #344: CloudKit Sync: Sync Metadata

smart_display

Loading stream…

Video #344: CloudKit Sync: Sync Metadata

Episode: Video #344 Date: Nov 3, 2025 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep344-cloudkit-sync-sync-metadata

Episode thumbnail

Description

We want SQLiteData to work seamlessly behind the scenes without you having to worry about how it works, but we also wanted to make sure you had full access to everything happening under the hood. Let’s explore the secret sync metadata table to see how we can fetch and even join against data related to sync, including sharing information and more.

Video

Cloudflare Stream video ID: abab3faec0c74426c24a714c0b9dd4fd Local file: video_344_cloudkit-sync-sync-metadata.mp4 *(download with --video 344)*

References

Transcript

0:05

I know I’m sounding like a broken record, but it’s incredible that we were able to add a feature to our reminders app that allows us to collaborate on a reminders list. We can both create, edit and delete reminders, and we can even both edit the cover image for the list.

0:19

It only took a few lines of code, it was bolted onto an app that was not specifically built to support data sharing or even iCloud synchronization, and it just magically works. Our SQLiteData library is handling a ton of edge cases and nuances having to do with conflict resolution, tracking record zones, and listening for changes in both the private and shared iCloud databases at the same time. Brandon

0:39

Building this kind of feature used to be the kind of thing we would dread. We could have spent months building a wonderful app for our users, and the moment we release it to the world we get back the first bit of feedback from our users, which is that they want their data synchronized to all their devices, and they want to share some of their data with friends or family. Well, we no longer have to dread this kind of feature request. SQLiteData takes care of all of that complexity for you, and you can focus your attention on just building your features in basically the same way you would have prior to worrying about sync and sharing. Stephen

1:15

And it’s worth mentioning that while SwiftData does have rudimentary support for iCloud synchronization, it does not support iCloud sharing at all. This means if you use SwiftData to handle persistence in your app, you simply will not be able to support allowing your users to share bits of their data with other iCloud users. You will have to wait with bated breath for each WWDC to roll around and hope that it eventually gets added. Brandon

1:38

And you might think that seamless iCloud data sharing would have been the ultimate cherry on top of this series of episodes, but we have an additional, tiny cherry to put on top of the existing cherry. We went above and beyond when building these tools because we wanted them to work seamlessly behind the scenes without you having to worry about how they work, but we also wanted to make sure you had full access to everything happening under the hood.

2:05

In particular, while you, the user of our library, gets to simply deal with SQLite databases and struct data types representing your tables, our library has to translate all of that information into CKRecord s to send out to CloudKit. And conversely, when we receive CKRecord s from CloudKit we have to update your SQLite database. You typically don’t have to think about these CKRecord s, but sometimes it can be handy to get access to them. They contain information that can be useful and that you don’t have access to from just your own SQLite database alone.

2:42

So, let’s see where this data is stored, what all is stored, and let’s see how it can be useful to customize our applications that make use of iCloud synchronization and sharing. The “meta-database”

2:56

As we have done a few times in this series, let’s go to the docs to see what it has to say about accessing the CloudKit metadata associated with our SQLite database. We will find that there is a section of the docs entitled “ Accessing CloudKit metadata ”, and in this section we will see there there is a secret table called SyncMetadata that stores all of this data. There is also information on how to query this table and even join your tables to it, but before getting into that let’s take a look at this table from within SQLite itself.

3:52

We can run the app in the simulator and filter the logs for the “SQLiteData” subsystem to see that at the very top of the logs we will find that the first thing printed out is something called the “meta-database connection”: Metadatabase connection: open "…/.db.metadata-iCloud.co.pointfree.ModernPersistence.CloudKit.sqlite"

4:10

This is a path to a SQLite database that actually lives right alongside your app’s own SQLite database. We can even perform a ls -a command for this directly to see it houses two SQLite databases: $ ls -a ~/Library/Developer/CoreSimulator/Devices/…/Documents/ . .. .db.metadata-iCloud.co.pointfree.ModernPersistence.CloudKit.sqlite .db.metadata-iCloud.co.pointfree.ModernPersistence.CloudKit.sqlite-shm .db.metadata-iCloud.co.pointfree.ModernPersistence.CloudKit.sqlite-wal db.sqlite db.sqlite-shm db.sqlite-wal

4:28

This database holds a whole bunch of metadata that is related to synchronization and record sharing. It is very important for us to track this information, but it would not have been appropriate for us to create a bunch of tables in your database. That would run the risk of table names conflicting, and also it would just be really confusing for you to open your own database and see a bunch of tables you didn’t create. It can also wreak havoc with how GRDB’s migration tool works because it will see a bunch of tables that it does not recognize as part of its migration and will think the schema has deviated from your Swift types, and so device to completely erase the database and start from scratch when using the eraseDatabaseOnSchemaChange setting.

5:30

So, we create a separate database called the “meta-database”. Let’s open the database in the console… $ sqlite3 .db.metadata-iCloud.co.pointfree.ModernPersistence.CloudKit.sqlite

5:41

In this database we will find 5 tables: sqlite> select name from sqlite_schema ...> where type = 'table'; … sqlitedata_icloud_metadata sqlitedata_icloud_recordTypes sqlitedata_icloud_stateSerialization sqlitedata_icloud_unsyncedRecordIDs sqlitedata_icloud_pendingRecordZoneChanges

6:10

The first table, sqlitedata_icloud_metadata is the most important one, and it’s the one that was mentioned in the docs. It holds information about every synchronized row across every synchronized table in your database. This table has as many rows as all of your synchronized tables combined, and its column contain a bunch of information about your data that is important for us to have quick access to.

6:57

At a bare minimum we need the primary key and type of all of your records. We also need the zone and owner name for each record, which helps us figure out which records belong in our zone or if they belong to someone else’s zone and has just been shared with us.

8:24

We also store the parent’s primary key and record type for each record. It is important for us to have easy access to this information because we often need to perform a recursive query when a record is edited to find the root most record that it belongs to.

9:22

Then there are columns for holding onto the last known server record, which is a CKRecord that is encoded to bytes and stored as a binary blob in SQLite. There is also a column for the CKShare of a record if there is one. If we sort on this column we will see I have exactly one record with a share, and that is the reminders list I previously shared.

10:08

This table can be pretty intimidating to take in all at once, but luckily there is a Swift data type that represents this table, and we can search for SyncMetadata to find its definition: @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Table("sqlitedata_icloud_metadata") public struct SyncMetadata: Hashable, Identifiable, Sendable { @Selection public struct ID: Hashable { public let recordPrimaryKey: String public let recordType: String } public let id: ID public let zoneName: String public let ownerName: String @Column(generated: .virtual) public let recordName: String @Selection public struct ParentID: Hashable { public let parentRecordPrimaryKey: String public let parentRecordType: String } public let parentRecordID: ID @Column(generated: .virtual) public let parentRecordName: String? @Column(as: CKRecord?.SystemFieldsRepresentation.self) public let lastKnownServerRecord: CKRecord? @Column(as: CKShare?.SystemFieldsRepresentation.self) public let share: CKShare? @Column(generated: .virtual) public let hasLastKnownServerRecord: Bool @Column(generated: .virtual) public let isShared: Bool public let userModificationTime: Int64 }

10:38

We can see we are using the @Table macro to define this table, and can easily see all the properties available with documentation on their use. The best part is that this type is public , which means we can actually query for its data. This will be extremely powerful in a moment, but let’s take a look at the other tables.

11:04

All of the other tables are purely implementation details and have no public API exposing their functionality, so you don’t have to worry about them too much. But for a quick overview, there’s a table called sqlitedata_icloud_recordTypes table that holds the schema of all synchronized tables. We use this information to figure out when a table is migrated or added so that we can either update locally stored data with the freshest data from the server, or so that we can send local data to the server

11:40

Next there’s a sqlitedata_icloud_stateSerialization . There are two rows, one represents the state of the private sync engine and the other represents the state of the shared sync engine. These state values allow us to communicate with iCloud to fetch and send the difference in values since the last time we synchronized..

12:10

Next there’s a table called sqlitedata_icloud_unsyncedRecordIDs , which is currently empty, but if it had rows it would represent records that iCloud sent to us that we do not understand, such as if another device out there has a newer version of the schema that has created records for a table this current device does not yet have. We use this information to re-synchronize these record IDs once we do have access to the table on this device.

12:38

And finally there is sqlitedata_icloud_pendingRecordZoneChanges . It’s currently empty, and its used by the library to cache changes that were made while the sync engine was off so that we can re-run them once the sync engine is started again.

13:05

So, that’s a quick tour of the meta-database that SQLiteData maintains right alongside your own database, and this database can unlock some true super powers in an app. To get our feet wet with this advanced feature, let’s start by adding a very simple feature to the app. As we saw a moment ago, it was possible for me to delete a reminders list that I shared, and that automatically unshared and removed the list from your device Stephen.

13:31

It might be a better user experience if we let the user know that the act of deleting that list is going to also un-share it from all participants. But to do this we need to be able to determine if the reminders list we are trying to delete is a shared list, and further that we are the owner of the list.

13:53

Luckily this is quite easy to do with the SyncMetadata table. First, let’s find where we are currently deleting the list in our application logic. The delete button is exposed as a swipe action on the row, which calls a method on the model that encapsulates the logic and behavior for this feature: func deleteButtonTapped(remindersList: RemindersList) { withErrorReporting { try database.write { db in try RemindersList .delete(remindersList) .execute(db) } } }

14:14

We want to update this method to first check the SyncMetadata to see if this list we are deleting is shared and owned by us. If it is, then we will first show an alert to the user asking them to confirm deleting the list, and otherwise we will go ahead and delete the list right away.

14:35

Since the SyncMetadata type is a SQLite table just like any other SQLite table we have dealt with, we can query for it using our database connection. So, let’s start a read transaction with our database: database.read { db in }

14:48

The SyncMetadata table is a primary keyed table, and so has a special find method that allows finding metadata by its ID: SyncMetadata.find(<#QueryExpression#>)

15:00

And further, primary keyed tables like our reminders list has a special property for deriving a SyncMetadata.ID , which can be used to look up the associated metadata: SyncMetadata.find(remindersList.syncMetadataID)

15:12

This is a SQL query that represents selecting the one metadata row associated with our reminders list, and to execute and fetch the actual metadata we can invoke the fetchOne method: try SyncMetadata.find(remindersList.syncMetadataID) .fetchOne(db)

15:25

If this query fails it can only mean programmer error, and so there’s nothing we should display to the user, so let’s wrap it in withErrorReporting and assign the returned value to a metadata variable: let metadata = withErrorReporting { try database.read { db in try SyncMetadata.find(remindersList.syncMetadataID) .fetchOne(db) } }

15:47

We have now loaded the sync metadata for our reminders list, if it exists. I want to take a moment to poke around inside this value so that we can see what all it holds. To do that I am going to put in some breakpoints so that we can print the contents of the value in the debugger: if let metadata { print("!!!") } else { print("!!!") }

16:10

Let’s run the app in the simulator and delete one of my private lists…

16:17

Well, unfortunately the breakpoint in the else branch is caught and we have a purple runtime warning: Caught error: SQLite error 1: no such table: sqlitedata_icloud_metadata - while executing SELECT "sqlitedata_icloud_metadata"."recordPrimaryKey", "sqlitedata_icloud_metadata"."recordType", "sqlitedata_icloud_metadata"."zoneName", "sqlitedata_icloud_metadata"."ownerName", "sqlitedata_icloud_metadata"."recordName", "sqlitedata_icloud_metadata"."parentRecordPrimaryKey", "sqlitedata_icloud_metadata"."parentRecordType", "sqlitedata_icloud_metadata"."parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord", "sqlitedata_icloud_metadata"."_lastKnownServerRecordAllFields", "sqlitedata_icloud_metadata"."share", "sqlitedata_icloud_metadata"."_isDeleted", "sqlitedata_icloud_metadata"."hasLastKnownServerRecord", "sqlitedata_icloud_metadata"."isShared", "sqlitedata_icloud_metadata"."userModificationTime" FROM "sqlitedata_icloud_metadata" WHERE ("sqlitedata_icloud_metadata"."recordPrimaryKey", "sqlitedata_icloud_metadata"."recordType") IN ((?, ?)) LIMIT ? For some reason our database connection cannot see the metadata table.

16:33

Well, this is happening because, as we saw a moment ago, the meta-database is a completely separate SQLite database from your database: ls -a ~/Library/Developer/CoreSimulator/Devices/…/Documents/ . .. .db.metadata-iCloud.co.pointfree.ModernPersistence.CloudKit.sqlite .db.metadata-iCloud.co.pointfree.ModernPersistence.CloudKit.sqlite-shm .db.metadata-iCloud.co.pointfree.ModernPersistence.CloudKit.sqlite-wal db.sqlite db.sqlite-shm db.sqlite-wal

16:43

So there should be no expectation that we can magically query for things in the .db.metadata-iCloud.co.pointfree.ModernPersistence.CloudKit.sqlite from a connection to the db.sqlite file.

16:54

However, we can magically start performing such queries if we take on additional step in the bootstrapping of our database connection, and it is in fact detailed in the docs but I skipped right past it: Important In order to query the SyncMetadata table from your database connection you will need to attach the meta-database to your database connection. This can be done with the attachMetadatabase(containerIdentifier:) method defined on Database . See Setting up a SyncEngine for more information on how to do this.

17:23

So, right in the prepareDatabase configuration closure, which is invoked with every connection made in the database pool, including the writer connection and multiple readers, we can attach the meta-database connection: configuration.prepareDatabase { db in try db.attachMetadatabase() … }

17:54

With that done we can re-run the app in the simulator, delete a private reminders list, and now we get caught on the breakpoint after the metadata value has been successfully unwrapped.

18:07

And if we print the contents of the metadata variable we will find lots of stuff inside: (lldb) po metadata ▿ Optional<SyncMetadata> ▿ some : SyncMetadata ▿ id : ID - recordPrimaryKey : "7110712d-cd2c-4edf-92c3-14a85492a5ae" - recordType : "remindersLists" - zoneName : "co.pointfree.SQLiteData.defaultZone" - ownerName : "__defaultOwner__" - recordName : "7110712d-cd2c-4edf-92c3-14a85492a5ae:remindersLists" - parentRecordID : nil - parentRecordName : nil ▿ lastKnownServerRecord : Optional<CKRecord> some : … ▿ _lastKnownServerRecordAllFields : Optional<CKRecord> some : … - share : nil - _isDeleted : false - hasLastKnownServerRecord : true - isShared : false - userModificationTime : 1759342758793260000

18:11

There’s quite a bit in here, but the piece we are most interested in is whether or not this list is shared: (lldb) po metadata.isShared false And it’s not.

18:22

We even have access to the actual CKShare that backs this list: (lldb) po metadata.share nil …but of course it’s nil since the list is not actually shared.

18:31

So, in addition to unwrapping the metadata value we will further check if it shared: if let metadata, metadata.isShared { print("!!!") } else { print("!!!") }

18:41

Now let’s re-run the app again and this time we will delete a list that I have shared previously. However, in the last episode I already deleted my shared “Point-Free” list so that we could show that our library correctly unshares the list and removes it from all participants’ devices.

18:56

So, to test things now I need to create a new one, but this time I will just generate a public link for the list…

19:14

And a moment later we will see that the share was synchronized to CloudKit: SQLiteData (private.db) fetchedRecordZoneChanges ┏━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ✔︎ Modified │ cloudkit.share │ share-b3557a06-2a09-4df4-91f8-10c746adeeff:remindersLists │ │ ✔︎ Modified │ remindersLists │ b3557a06-2a09-4df4-91f8-10c746adeeff:remindersLists │ └────────────┴────────────────┴───────────────────────────────────────────────────────────┘

19:22

Now let’s try deleting this list, and this time we get caught in the breakpoint in the if branch, which means this reminders list is definitely shared.

19:31

There’s another property on the metadata called share : (lldb) po metadata.share ▿ Optional<CKShare> - some : …

19:34

This gives us access to the actual CKShare that powers the sharing of this reminders list. This is the record that was created by the share(record:) method on the sync engine that we previously invoked to share the list, which was subsequently sent off to iCloud for processing, and then sent back to us with all the freshest data.

19:56

This CKShare has all types of interesting things in it. For one thing, it has a property called currentUserParticipant , which represents the current iCloud user on this device that is a participant in the share: (lldb) po metadata!.share?.currentUserParticipant ▿ Optional<CKShareParticipant> - some : …

20:20

And this participant object also has some interesting properties, such as a role that we can check if it equals .owner : (lldb) po metadata!.share?.currentUserParticipant?.role == .owner true

20:51

This right here will tell us if the currently logged in user on this device is the owner of the reminders list. And that is exactly what we need in order to determine if we show an alert or not: if let metadata, let share = metadata.share, let currentUserParticipant = share.currentUserParticipant, currentUserParticipant.role == .owner { // TODO: Show an alert to confirm deleting reminders list } else { withErrorReporting { try database.write { db in try RemindersList .delete(remindersList) .execute(db) } } }

21:39

There’s a lot of power to being familiar with the CloudKit types exposed in the metadata, such as this CKShare . And in general we feel that you will only be able to wield our tools better if you are very familiar with some of CloudKit’s tools too.

22:02

We’ve now done the hard part for this feature. The easy part is to show an alert, and when the user confirms deleting the list we will then actually delete the list.

22:10

To do this we will add some state to the observable model that powers this feature to represent whether or not we are showing an alert for deleting this reminders list: var deleteRemindersListAlert: RemindersList?

22:35

Then we can populate this state when we want to show the alert: if let metadata, let share = metadata.share, let currentUserParticipant = share.currentUserParticipant, currentUserParticipant.role == .owner { deleteRemindersListAlert = remindersList } else { … }

22:43

Next in the view we want to drive the presentation of an alert from this state. The SwiftUI API for presenting alerts is a little funny. We want to present and dismiss the alert from the single piece of optional state we just added to the model, but the only API that uses optional state takes both a binding of a boolean and an additional piece of optional state: .alert( <#LocalizedStringKey#>, isPresented: <#Binding<Bool>#>, presenting: <#T?#>, actions: <#(T) -> View#>, message: <#(T) -> View#> )

23:29

To keep us from having to introduce a piece of boolean state to our domain in addition to the optional state we can define a boolean computed property with a get and set : class RemindersListModel { … var deleteRemindersListAlert: RemindersList? var isDeleteRemindersListAlertPresented: Bool { get { deleteRemindersListAlert != nil } set { guard !deleteRemindersListAlert else { return } deleteRemindersListAlert = nil } } … }

24:33

This allows us to derive a binding of a boolean via dynamic member lookup: .alert( <#LocalizedStringKey#>, isPresented: $model.isDeleteRemindersListAlertPresented, presenting: <#T?#>, actions: <#(T) -> View#>, message: <#(T) -> View#> )

24:39

And then we can also pass along the optional data too: .alert( <#LocalizedStringKey#>, isPresented: $model.isDeleteRemindersListAlertPresented, presenting: model.deleteRemindersListAlert, actions: <#(T) -> View#>, message: <#(T) -> View#> )

24:44

Next we can fill out the title, actions and message for the alert: .alert( "Delete shared reminders list?", isPresented: $model.isDeleteRemindersListAlertPresented, presenting: model.deleteRemindersListAlert, actions: { remindersList in Button(role: .destructive) { } }, message: { _ in Text( """ This reminders list is shared with other iCloud users. \ Deleting it will remove it from their devices. \ Do you want to proceed? """ ) } )

25:41

And in the button action closure we will invoke a method on the model for finishing off the job of deleting the reminders list: Button(role: .destructive) { model.confirmDeleteRemindersListButtonTapped( remindersList: remindersList ) }

26:11

…which can be implemented like so: func confirmDeleteRemindersListButtonTapped( remindersList: RemindersList ) { withErrorReporting { try database.write { db in try RemindersList .delete(remindersList) .execute(db) } } }

26:56

And incredibly, that is all it takes to implement this feature. We can run the app in the simulator, try to delete the “Point-Free” list, and I am immediately confronted with an alert asking me to confirm my decision. And once I confirm I will see in the logs that both the reminders list and share were deleted: SQLiteData (private.db) sentRecordZoneChanges ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ action ┃ recordType ┃ recordName ┃ ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ ⌫ Deleted │ │ b3557a06-2a09-4df4-91f8-10c746adeeff:remindersLists │ │ ⌫ Deleted │ │ share-b3557a06-2a09-4df4-91f8-10c746adeeff:remindersLists │ └───────────┴────────────┴───────────────────────────────────────────────────────────┘

27:40

Before moving on, there’s one improvement we can make to our code. We introduced an ad hoc computed property to drive our alert navigation from optional state. To avoid having to do this over and over again, we can introduce a reusable helper directly on optionals: extension Optional { var isPresented: Bool { get { self != nil } set { guard !newValue else { return } self = nil } } }

29:13

This allows us to delete our ad hoc computed property and transform our binding of an optional into a binding of a boolean via dynamic member lookup, instead: isPresented: $model.deleteRemindersListAlert.isPresented,

29:53

But, even though I keep deleting this shared list I really do want to share it with you Stephen, so I am going to create it one more time, I’m going to generate a public link, copy it, and then text you that link. And there it goes. Accessing share metadata

31:08

So this is pretty incredible. All of the underlying metadata for CloudKit is instantly available to users of our library. We were able to query the SyncMetadata table to find the metadata associated with a reminders list, and then in that metadata could determine if a list is being shared or not, as well as if the current logged in iCloud account is the owner of that list. And only if they are the owner do we ask them to confirm they want to delete the list when they swipe on the row. Stephen

31:39

And so we’ve seen it’s already powerful to be able to query the SyncMetadata table, but things get even more powerful when we join our tables to the SyncMetadata table. That allows us to run queries such as selecting all reminders lists that are shared, or not shared. It would even be possible to select all reminders lists which are shared and for which the current iCloud user is the owner of the list. You can even select a comma separated list of the participant names from the share, all in a single SQL query.

32:04

It’s really incredible to see how all of the iCloud metadata is made available to us from the SQL layer, and it allows us to cram more and more of our querying logic into SQL where it belongs.

32:13

Let’s explore these topics by improving the root view of our app to separate the reminders lists that are private to our iCloud from the reminders lists that are shared with others.

32:25

Right now the user experience for our root view isn’t great. We see a bunch of reminders lists I have created, but secretly one of these is not like the others. The “Point-Free” list is actually a list that you shared with me, but I have no way of knowing that.

32:50

Wouldn’t it be nice if we could display this list in a different fashion so that it’s clear that it’s shared with us? Well, it absolutely is possible, and it’s even pretty easy thanks to the SyncMetadata table.

33:00

Currently we fetch all reminders lists with this one big query: @ObservationIgnored @FetchAll( RemindersList .group(by: \.id) .order(by: \.position) .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted } .select { RemindersListRow.Columns( incompleteRemindersCount: $1.count(), remindersList: $0 ) }, animation: .default ) var remindersListRows

33:06

This fetches all reminders lists, ordered by their position field, and further joins the reminders table to the lists table so that we can count how many incomplete reminders are in each list. And all of this data is bundled up into a custom data type holding all the data we need from the database: @Selection struct RemindersListRow { let incompleteRemindersCount: Int let remindersList: RemindersList }

33:23

This allows our queries to be very efficient by decoding only the data we need.

33:27

Let’s see what it would take to further add to this custom data type a boolean that determines if the list has been shared or not: @Selection struct RemindersListRow { let incompleteRemindersCount: Int let isShared: Bool let remindersList: RemindersList }

33:38

Just to get things compiling we can hard code false for this when constructing RemindersListRow.Columns : RemindersListRow.Columns( incompleteRemindersCount: $1.count(), isShared: false, remindersList: $0 )

33:47

Now we already saw that the SyncMetadata table has everything we need to determine if a list is being shared, but the question is: how do we get access to SyncMetadata in this query?

33:56

Well, we can do exactly what we do anytime we need data from a related table for each row in a table we are querying: we can use join! In fact, that’s already what we are doing to get information about reminders for each list we query for: .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted }

34:07

And believe it or not, we can join the SyncMetadata table in this query to get access to each reminders lists’ metadata. We will do it as a left join just in case there is no metadata for a list, which can happen for a newly created list before it has had a chance to synchronize with iCloud: .leftJoin(SyncMetadata.all) {}

34:24

But what should the join constraint me? We have access to 3 table definitions in this closure, but we only need the first and third, which is the reminders table and sync metadata table: .leftJoin(SyncMetadata.all) { remindersList, _, syncMetadata in }

34:40

The sync metadata table has two columns on it that uniquely identify the piece of data from our SQLite database that the metadata is associated with. If we look at the SyncMetadata struct again we will see how these ID is set up: @Selection public struct ID: Hashable, Sendable { public var recordPrimaryKey: String public var recordType: String } public let id: ID

34:56

The ID consists of two pieces of information: a recordPrimaryKey and a recordType . The record type is the name of the table that the metadata is associated with, and the record primary key is the exact row of the table the metadata is associated with. These two fields are how we can look up the metadata belonging to a reminders list in the join constraint.

35:11

The library even provides a helper to make this super easy. Every primary keyed table definition gets a syncMetadataID property that can be used to compare against the SyncMetadata table’s ID: .leftJoin(SyncMetadata.all) { $0.syncMetadataID.eq($2.id) }

35:40

And that’s all it takes to join these tables together.

35:44

Next the select statement below now has 3 arguments instead of 2, and the last argument is the table definition of the SyncMetadata table. We can plug its isShared property into the RemindersListRow.Columns initializer, but we have to be careful here. Left joining a table naturally optionalizes all of its fields because there may not be any row on the right side corresponding to a row on the left. And so isShared is actually optional in this context, but we can use the SQL ifnull operator to coalesce it to false if it is ever nil : .select { RemindersListRow.Columns( incompleteRemindersCount: $1.count(), isShared: $2.isShared.ifnull(false), remindersList: $0 ) },

36:15

That’s all it takes to query for the share information in SQL. It’s kind of incredible to think that we are joining tables from two completely different databases, but that’s what’s happening.

36:24

Now that we have this data available to us, let’s start using it in the UI. We will add a property to the RemindersListRow : struct RemindersListRow: View { let isShared: Bool … }

36:38

And we will wrap the title of the reminders list in a leading aligned VStack to display a “Shared” label if the list is shared: VStack(alignment: .leading) { Text(remindersList.title) if isShared { Text("Shared") } }

36:53

We will also need to update the preview to pass along the data: #Preview { NavigationStack { List { RemindersListRow( incompleteRemindersCount: 10, isShared: false, remindersList: RemindersList( id: UUID(1), title: "Personal" ) ) } } }

37:04

As well as in the RemindersListsView : RemindersListRow( incompleteRemindersCount: row.incompleteRemindersCount, isShared: row.isShared, remindersList: row.remindersList )

37:21

And incredibly that’s all it takes. When I run the app in the simulator we will see that the list you previously shared with me is rendered differently from the users. It has a “Share” label under it.

37:31

Even better, these rows will live update as lists are shared and unshared. For example, I will swipe on one of my private lists, open the share sheet, and generate a public link for it. A moment later we will see that everything synchronizes with iCloud and the the “Share” label appears under the list. And further, if I open the share sheet for that list again, tap the “Stop sharing” button, and confirm, a moment later the “Shared” label will disappear.

37:59

And in the logs we will see the complex query we wrote being executed to load this data:

SELECT 38:07

I don’t know about you, but I wouldn’t want to have to write this query by hand, and I’m glad we can leverage our StructuredQueries library to aid in building such complex queries.

SELECT 38:15

But I think we can improve the display of our shared reminders even more. Right now it’s a bit subtle which lists are shared and which are private. Let’s make this more obvious by separating the list into two sections, one for private lists and one for shared lists.

SELECT 38:28

We can can tweak our existing query to only query for private reminders by adding a where clause to select only reminders whose isShared in the metadata is false : .where { !$2.isShared.ifnull(false) }

SELECT 38:47

And then we can rename the variable to signify that this is just private reminders: var privateRemindersListRows

SELECT 38:51

And to get just the shared lists we can copy-and-paste this variable with its query and negate the where clause: @ObservationIgnored @FetchAll( RemindersList .group(by: \.id) .order(by: \.position) .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted } .leftJoin(SyncMetadata.all) { $0.syncMetadataID.eq($2.id) } .where { $2.isShared.ifnull(false) } .select { RemindersListRow.Columns( incompleteRemindersCount: $1.count(), isShared: $2.isShared.ifnull(false), remindersList: $0 ) }, animation: .default ) var sharedRemindersListRows This is of course quite a bit of code to copy-and-paste, but we will get things working first and then make the code nicer.

SELECT 39:07

We will update the view to first show all of the private lists in a section: ForEach(model.privateRemindersListRows, id: \.remindersList.id) { row in … }

SELECT 39:22

And then right below that, if there are any shared lists, we will display them in a section titled “Shared lists”: if !model.sharedRemindersListRows.isEmpty { Section { ForEach( model.sharedRemindersListRows, id: \.remindersList.id ) { row in Button { model.detailTapped( detailType: .remindersList(row.remindersList) ) } label: { RemindersListRow( incompleteRemindersCount: row.incompleteRemindersCount, isShared: row.isShared, remindersList: row.remindersList ) .foregroundColor(.primary) } .swipeActions { Button(role: .destructive) { model.deleteButtonTapped( remindersList: row.remindersList ) } label: { Image(systemName: "trash") } Button { model.editButtonTapped( remindersList: row.remindersList ) } label: { Image(systemName: "info.circle") } Button { Task { await model.shareButtonTapped( remindersList: row.remindersList ) } } label: { Image(systemName: "square.and.arrow.up.fill") } .tint(Color.blue) } } .onMove { source, destination in model.moveRemindersList( fromOffsets: source, toOffset: destination ) } } header: { Text("Shared lists") .font(.largeTitle) .bold() .foregroundStyle(.black) .textCase(nil) } }

SELECT 40:40

And incredibly that’s all it takes. If we run the app in the simulator we will see all shared lists in their own section, making it very clear which lists are private and which are shared with others. And further, sharing a list automatically makes it down to the shared section, and stopping the sharing of a list moves it right back to the private section. Next time: SQL “views”

SELECT 41:20

We have now greatly improved the user experience for our reminders lists by separating the private lists from the shared ones. To accomplish this we did something kind of incredible We joined our SQL tables to the sync metadata table that exists in a completely different database. That’s right, we are joining across databases.

SELECT 41:36

And we do this so that we can inspect the iCloud metadata for each reminders list, and in particular whether or not that list is shared. The fact that SQLiteData does not hide this metadata from you, and really quite the opposite, makes it very public and even queryable, makes it so easy to implement features like this. Brandon

SELECT 41:52

But one not so ideal thing about what we just did is that we literally copied-and-pasted a large query just so that we could query for the private lists and then the shared lists. Luckily for us there are many ways for us to reuse the logic in our queries so that we don’t have to literally copy-and-paste code around. And we’ve shown a number of these techniques in past episodes, but we are going to show off a whole new one that is more appropriate to use here.

SELECT 42:17

There is a concept in SQL known as database “views”, and they allow you to define table-like entities in your database that are secretly backed by a query. This is a powerful tool to share complex logic amongst many queries and also hide the implementation details of the complex query.

SELECT 42:36

Let’s take a look…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 0344-sync-pt5 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 .