Video #309: Sharing with SQLite: The Problems
Episode: Video #309 Date: Jan 13, 2025 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep309-sharing-with-sqlite-the-problems

Description
Persisting app state to user defaults or a JSON file is simple and convenient, but it starts to break down when you need to present this data in more complex ways, and this is where SQLite really shines. Let’s get a handle on the problem with some state that is currently persisted to a JSON file, and let’s see how SQLite fixes it.
Video
Cloudflare Stream video ID: dd8dd143518019e1abb20aad26372617 Local file: video_309_sharing-with-sqlite-the-problems.mp4 *(download with --video 309)*
References
- Discussions
- Sharing
- SQLite
- GRDB
- 0309-sqlite-sharing-pt1
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
We ended last year by giving a tour of our new open source library called “Sharing”. It’s an extremely powerful library that allows you to instantly share state with multiple parts of an application, and persist it to external systems, such as user defaults, file storage, and more. And we showed how the library is compatible with SwiftUI views, observable models, UIKit view controllers, and there are even more places the library can be used that we haven’t shown yet. Stephen
— 0:31
But that tour barely scratched the surface of what the library is capable of. We only showed off the persistence strategies that come with the library, which are the appStorage , fileStorage and inMemory strategies. They are handy, but the real power of the library comes when defining your own custom persistence strategies. Brandon
— 0:47
We designed the library so that anybody can create their own persistence strategies that load data from and save data to external storage systems, and with a bit of extra work, one can even make sure that if the external source of data changes, the @Shared value will also automatically update. You can even make it so that your custom persistence strategy is easy to use in previews and tests. Stephen
— 1:12
The most obvious example of a custom persistence strategy would be a SQLite-backed strategy. What if you could hold state in your features that was secretly being persisted to a database on disk? And what if anytime the database updated your views immediately re-rendered? Brandon
— 1:27
It turns out it’s quite easy to accomplish this, and it’s amazing to see. But we’re going to start by showing why one might want to move away from the file storage strategy that comes with the library and towards a custom SQL-based persistence strategy.
— 1:41
Let’s take a look. The problem with file storage
— 1:44
One starts to encounter the limitations of file storage when one needs to perform complex operations on the data being persisted. We will explore this by looking at the code we built in our tour of the Sharing library , which if you recall is a simple counter app with the ability to fetch facts for numbers and save your favorite facts. But because the data held in a @Shared property: @ObservationIgnored @Shared(.favoriteFacts) var favoriteFacts
— 2:50
…precisely matches what is held on disk: [ { "id" : "F47C9EBC-CAE7-499C-A518-309E33318A6C", "number" : 2, "savedAt" : 753549105.502176, "value" : "2 is the price in cents per acre the USA bought Alaska from Russia." }, { "id" : "A9EC3C6D-9A21-4B7D-8E77-C06AA11B92C4", "number" : 4, "savedAt" : 753549109.219808, "value" : "4 is the number of strings on a tenor guitar, violin, a viola, a cello, double bass, a cuatro and a ukulele, and the number of string pairs on a mandolin." } ]
— 3:11
…it makes it difficult to perform complex querying or sorting logic on this data.
— 3:44
To see this concretely, let’s add some functionality to our feature that allows sorting the facts by either they date added, or by the number the fact corresponds to. We can start by introducing an enum that represents the various ways of sorting: enum Ordering: String, CaseIterable { case number = "Number", savedAt = "Saved at" }
— 4:42
And then we can introduce some new state to our model for representing the current sort: var ordering: Ordering = .number
— 4:56
And heck, let’s go above and beyond by even persisting this data to user defaults: @ObservationIgnored @Shared(.appStorage("ordering")) var ordering: Ordering = .number
— 5:46
Because the Ordering type is raw representable as a string you get immediate access to persisting that state to user defaults.
— 5:52
We can even go the extra mile by defining a type-safe key for this that also bakes in the default: extension SharedKey where Self == AppStorageKey<Ordering>.Default { static var ordering: Self { Self[.appStorage("ordering"), default: .number] } }
— 6:28
And now we can simply do: @ObservationIgnored @Shared(.ordering) var ordering
— 6:45
Next we can add a picker to the “Favorites” section: } header: { HStack { Text("Favorites (\(model.favoriteFacts.count))") Spacer() Picker("Sort", selection: .constant(Ordering.number)) { Section { ForEach(Ordering.allCases, id: \.self) { ordering in Text(ordering.rawValue) } } header: { Text("Sort by:") } } .textCase(nil) } }
— 8:12
But, the question is how do we derive a binding to the ordering field of the model. Remember we can’t mutate shared state directly: model.ordering = .number Setter for ‘ordering’ is unavailable: Use ‘$shared.withLock’ to modify a shared value with exclusive access. …because shared state is reference-y and can be mutated from any thread. The library forces you to attain a lock on the shared state in order to mutate it: model.$ordering.withLock { $0 = .number } So this means that technically it is not allowed to derive a binding directly from the bindable model like so: Picker("Sort", selection: $model.ordering) {
— 9:56
However, this does seem to compile. The fact that this compiles is just a bug in the Swift compiler when it comes to unavailable attributes. It turns out that the unavailable checker in Swift isn’t as strong as it should be, and under certain circumstances it does allow you to access APIs that should be inaccessible.
— 10:22
We do not encourage exploiting this bug of Swift, and instead you should use our dedicated tool that can turn any shared value into a binding: Picker("Sort", selection: Binding(model.$ordering)) {
— 11:16
Next we want to observe when the ordering state is changed so that we can sort the facts. We might hope we can simply tap into the didSet on the ordering field: @ObservationIgnored @Shared(.ordering) var ordering { didSet { /* Sort facts */ } }
— 11:37
But this sadly is not a good idea. It turns out that didSet and willSet have subtle behavior when used on fields that used property wrappers. The didSet will only be invoked when mutating ordering directly, but not when it is mutated through the underlying wrapped value, such as is the case with bindings.
— 11:59
This is even something that plagues vanilla SwiftUI. Take for example this view: struct DidSetView: View { @State var count = 0 { didSet { print("didSet") } } var body: some View { Form { Stepper("\(count)", value: $count) } } } #Preview { DidSetView() }
— 12:29
It has some local @State that is mutated through a stepper binding. If we run the preview, we will see that whenever we step the stepper, nothing is printed to the console. Because the binding is going strange to the _count.wrappedValue , the didSet is never invoked.
— 12:45
However, adding a button that mutates count directly does work as expected, and print to the console. Button("+") { count += 1 } didSet didSet didSet The binding, on the other hand, seems to bypass this property observer by mutating the underlying wrappedValue directly. We can even see that mutating directly but through the wrapped value in the button leads to no longer printing to the console. Button("+") { $count.wrappedValue += 1 }
— 13:35
In general we feel that willSet and didSet should probably never be used with property wrappers because they have such subtle behavior.
— 13:57
Luckily there are some alternatives. First, we could observe the change in a view by introducing the onChange view modifier: .onChange(of: ordering) { // Sort facts } This would work fine, but it would also put some logic in the view instead of the model, which not only makes it difficult to test, but it means if we were to reuse this model in another paradigm, like UIKit or even cross platform, we would need to remember to reimplement this logic or we would have a serious bug on our hands.
— 15:15
So instead, we will take advantage of a feature of the @Shared property wrapper, which is it exposes a publisher that emits when its value changes. We can subscriber to that publisher in the initializer: init() { $ordering.publisher.sink { ordering in // Sort facts } }
— 15:28
It’s important to note that this publisher emits on the willSet side of the mutation with the new value. This is similar to the @Published property wrapper and even Swift’s Observation tools like withObservationTracking . Emitting during willSet not only lets you compare the previous value with the new value, which would not be possible during didSet , but it also allows you to perform additional mutations and minimize the number of view computations done by SwiftUI. So we will keep this behavior in mind when we perform logic in the sink . We should explicitly pass along the new ordering value rather than rely on the current value, which is stale: init() { $ordering.publisher.sink { ordering in self.sortFavorites(ordering: ordering) } } func sortFacts(ordering: Ordering) { print("Old value", self.ordering) print("New value", ordering) }
— 16:57
And then we can switch on the ordering to figure out how we want to sort: private func sortFavorites() { switch ordering { case .number: $favoriteFacts.withLock { $0.sort(by: { $0.number < $1.number }) } case .savedAt: $favoriteFacts.withLock { $0.sort(by: { $0.savedAt > $1.savedAt }) } } }
— 17:49
And we should clean up the sink by weakifying the model and holding onto the cancellable: var cancellables: Set<AnyCancellable> = [] init() { $ordering.publisher.sink { [weak self] ordering in self?.sortFavorites(ordering: ordering) } .store(in: &cancellables) } It’s also important to note that this publisher emits immediately with the current value, so we do not need to also call sortFavorites beforehand to initialize the sort.
— 18:20
This is a good start, and if we run things, we’ll see that it does sort when changing the ordering, but it does not sort when adding a new fact.
— 19:16
And so maybe we need to also observe changes to favorite facts and perform a sort there: $favoriteFacts.publisher.sink { [weak self] facts in guard let self else { return } self.sortFavorites(self.ordering) } .store(in: &cancellables)
— 19:28
But this seems inefficient to re-sort the facts every time they change, and as written right now, there is an infinite loop. When the favoriteFacts change, the publisher emits, which mutates the favoriteFacts , which causes the publisher to emit again, and on and on and on. We’d have to do more work here to make sure to not mutate the favorites if they are already sorted.
— 19:55
So instead of doing all that work, we could instead re-sort the favorites after a new fact is added to the collection: $favoriteFacts.withLock { $0.insert( Fact(number: count, savedAt: Date(), value: fact), at: 0 ) } sortFavorites(ordering: ordering)
— 20:16
And that’s all it takes to implement the basics of this feature, and we can see that it works by running the preview.
— 20:48
However, there are a few problems with this approach. First of all, we are re-saving the data to the file system each time we sort. We are forcing the representation of the data stored on disk to match exactly what we are showing in the app.
— 21:07
We can see this concretely by opening up the JSON file that holds the data. If we re-order the items in the app we will see that the file was resaved with the new order. That means every time we change the ordering we are technically re-encoding the data into JSON and re-saving the data all over again. That’s a lot of unnecessary work being performed in order to just sort the data.
— 21:52
Also if I add a new fact to the JSON directly, like say add a fact for the number 1 at the bottom: { "id" : "deadbeef-dead-beef-dead-beefdeadbeef", "number" : 1, "savedAt" : 755030659.679318, "value" : "1 is a good number!" }
— 22:05
…then the moment I hit save the fact shows in the simulator, which is great, but it is not sorted properly. We would need to perform extra work to detect when the file is saved externally so that we can resort the data.
— 22:22
Also the code we wrote to maintain this ordering is a bit annoying. We have to vigilant in making sure we sort the facts anytime the collection is changed in a meaningful way.
— 22:42
An alternative to this is to not sort the favoriteFacts state directly by removing the publisher, cancellables, and sortFavorites method, and instead adding a computed property that sorts the facts: var sortedFavorites: [Fact] { switch ordering { case .number: favoriteFacts.sorted(by: { $0.number < $1.number }) case .savedAt: favoriteFacts.sorted(by: { $0.savedAt > $1.savedAt }) } }
— 23:42
Then in the view we will use this computed property in the ForEach : ForEach(model.sortedFavorites) { fact in Text(fact.value) }
— 23:53
This also works, and it some ways seems simpler than sorting the data stored on disk, but it also inefficient in its own way. Every time the view re-computes its body we are going to sort these facts again, even if the facts didn’t change at all. This includes when we are just incrementing or decrementing the count. Also we may forget that the sortedFavorites property is secretly hiding an expensive operation, and we may access it twice in the view: Text("Favorites (\(model.sortedFavorites.count))")
— 24:53
We have now unwittingly sorted the facts twice, and that can start to slow down the view. Refactoring with SQLite and GRDB
— 25:03
This clearly shows the problem with using the file storage persistence strategy when complex querying and sorting capabilities are needed. And in general we feel that the file storage strategy works for many kinds of simple data and can be great to get your feet wet with your app, but often you will find that you need to reach for something a bit more advanced as your app gets more complex. Stephen
— 25:24
And this is where SQLite really shines. It allows us to separate how the data is stored on disk from how we want to “view” the data. We can construct lots of lightweight views into the data for populating our views, all without ever touching how the data is stored on disk.
— 25:39
So, what if we could continue holding onto our @Shared facts state as we are now, but secretly the state was persisted to a database. And further, what if the @Shared property wrapper even had the ability to hold onto a SQL query that powered how the state was fetched from the database? This means we could embrace the full powers of SQLite in our apps while still holding onto the state as if it was just any other kind of regular state. And further, what if the whole thing was 100% testable?
— 26:05
It’s possible, and it’s absolutely amazing to see. We are going to start by first naively updating our app to use SQLite instead of file storage, and then we will see how the tools of the Sharing library can help make everything much, much nicer.
— 26:18
Let’s dig in.
— 26:21
Let’s start by introducing a new domain type that represents the data held in the database. We already have a data type we were using to represent facts saved to disk: struct Fact: Codable, Equatable, Identifiable { … }
— 26:30
But the SQLite version of this type has to conform to a lot more protocols from GRDB, like the MutablePersistableRecord protocol for saving data to the table, and the FetchableRecord in order to query for data. And further the SQLite data type will use an Int64 for its primary key, whereas this Fact type currently uses a UUID.
— 26:50
So, rather than changing this type and breaking a bunch of existing code, let’s rename this type to LegacyFact : struct LegacyFact: Codable, Equatable, Identifiable { … }
— 26:59
…and then we will add GRDB as a dependency to this project. At the time of recording this episode the newest version of GRDB with Swift 6 support is in beta, so we will be explicit by depending on the branch v7.0.0-beta.6.
— 27:18
Now we will introduce a new type for the Fact that is stored in the SQLite database, and we will add this in a new file called Schema.swift: import Foundation import GRDB struct Fact: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord { static let databaseTableName = "facts" var id: Int64? var number: Int var savedAt: Date var value: String mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID } } We have also gone ahead and done a few things that we discussed during our SQLite series of episodes. We conform the type to FetchableRecord so that GRDB can generate SQLite queries for fetching this record from the database. And we conform to MutablePersistableRecord so that GRDB can generate queries for inserting and updating rows for this record in the database.
— 27:45
We have also given an explicit name to the table representing this data type: static let databaseTableName = "facts"
— 27:53
By default it would have been “fact”, but we think the plural “facts” reads better.
— 27:55
And lastly, once a record is inserted we will immediately mutate the value so that it holds onto its assigned ID from SQLite: mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID }
— 28:05
With that done we can construct a connection to the SQLite database on disk. Doing so requires a decent amount of work, such as constructing the path to the DB, configuring the DB and migrating the DB, and so we will put all of this work in a static helper defined on GRDB’s DatabaseQueue type: extension DatabaseQueue { static var appDatabase: DatabaseQueue { let databaseQueue: DatabaseQueue return databaseQueue } }
— 28:39
We can construct a path to where we want the SQLite database to be saved, as well as print it out to the console so that it is easy to open and inspect: let path = URL .documentsDirectory .appending(component: "db.sqlite") .path() print("open", path)
— 29:05
Now we can construct a database connection with that path: databaseQueue = try! DatabaseQueue(path: path)
— 29:20
We can also configure the database with some options that make working with it friendlier. For example, we can make the database connection log each SQL query performed by doing this: var configuration = Configuration() configuration.prepareDatabase { db in db.trace { print($0) } } databaseQueue = try! DatabaseQueue( path: path, configuration: configuration )
— 29:51
We can even amp this up a bit. We can also have SQLite print out how long each request took: var configuration = Configuration() configuration.prepareDatabase { db in db.trace(options: .profile) { print($0) } }
— 30:02
And currently print($0) will print the raw SQL statement being executed, but for security reasons will not print any of the data bound to the query. But in debug mode that can be super handy, so let’s enable that too: var configuration = Configuration() configuration.prepareDatabase { db in db.trace(options: .profile) { #if DEBUG print($0.expandedDescription) #else print($0) #endif } }
— 30:27
And to be a little future forward, let’s also make it so that when running our code in a preview or in tests we use an in-memory database. This will make it so that each preview and test gets a quarantined database that can’t infect other previews, which is something we discussed during our SQLite series a few weeks ago: if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == nil, !isTesting { let path = URL .documentsDirectory .appending(component: "db.sqlite") .path() print("open", path) databaseQueue = try! DatabaseQueue( path: path, configuration: configuration ) } else { databaseQueue = try! DatabaseQueue(configuration: configuration) }
— 31:39
Next we will create a migrator: var migrator = DatabaseMigrator()
— 31:47
…and then register a migration for creating the table that will hold the facts: migrator.registerMigration("Add 'facts' table") { db in }
— 31:59
We can create the table using a nice API that GRDB provides us: try db.create(table: "facts") { table in table.autoIncrementedPrimaryKey("id") table.column("number", .integer).notNull() table.column("savedAt", .datetime).notNull() table.column("value", .text).notNull() }
— 32:57
And then we can run the migration: do { try migrator.migrate(databaseQueue) } catch { reportIssue(error) }
— 33:23
There is one fun thing we can do at the end of the “Add ‘facts’ table” migration. Currently our users may have many facts saved to a JSON file stored on disk. It would be a bummer if they lost all of that data and we started them with a blank slate.
— 33:37
So, after creating the “facts” table, why not also migrate any existing data held in a JSON file to the database? We can start by loading the facts from disk using the existing shared file storage: @Shared(.favoriteFacts) var legacyFacts
— 34:14
Then we can loop over the facts and insert them into the database: for fact in favoriteFacts { try db.execute(literal: """ INSERT INTO "facts" ("number", "savedAt", "value")
VALUES 34:49
And finally we can clean up the legacy facts by removing them: $legacyFacts.withLock { $0.removeAll() }
VALUES 35:04
Now let’s update the view in order to make use of the facts stored in the database. We will start by weakening the FactFeatureModel to hold onto a plain property of the state instead of shared state: var favoriteFacts: [Fact] = []
VALUES 35:30
We will be able to bring back the @Shared property wrapper once we have fully integrated GRDB into the sharing tools, but for now we are just trying to get the basics of SQLite into this app.
VALUES 35:38
This creates a few compiler errors where we are mutating the shared state to insert a new fact: $favoriteFacts.withLock { $0.insert( Fact(id: uuid(), number: count, savedAt: now, value: fact), at: 0 ) } …or delete facts: $favoriteFacts.withLock { $0.remove(atOffsets: indexSet) }
VALUES 35:53
We cannot reach into this state and mutate it directly because the favoriteFacts held in state currently has no connection to the database at all. If we were to insert or remove elements directly into that array it would not correspond to anything being actually saved to the database.
VALUES 36:07
Instead we will use the database connection in order to insert rows and delete rows. But, to do that we need to have access to the database queue in the model: import GRDB … let database: DatabaseQueue
VALUES 36:24
Which forces us to introduce an initializer: init(database: DatabaseQueue) { self.database = database }
VALUES 36:31
And now when the star icon is tapped to favorite a fact we will use the database queue to insert the fact into the database: func favoriteFactButtonTapped() { … do { try database.write { db in _ = try Fact(number: count, savedAt: now, value: fact) .inserted(db) } } catch { reportIssue(error) } }
VALUES 37:25
And similarly for deleting: func deleteFacts(indexSet: IndexSet) { … do { try database.write { db in _ = try Fact.deleteAll( db, ids: indexSet.map { favoriteFacts[$0].id } ) } } catch { reportIssue(error) } }
VALUES 38:13
And we will delete the helpers we created earlier for sorting favorites because soon that will all be handled by SQLite…
VALUES 38:29
…and we will go back to using the favoriteFacts property directly in the view: ForEach(model.favoriteFacts) { fact in Text(fact.value) }
VALUES 38:38
That mostly gets things compiling. We just have a preview down below that needs to be handed a database: #Preview { FactFeatureView(model: FactFeatureModel(database: .appDatabase)) }
VALUES 38:50
And same for the entry point of the app: @main struct TourOfSharingApp: App { let model: FactFeatureModel init() { self.model = FactFeatureModel(database: .appDatabase) } … }
VALUES 38:56
That gets things compiling, but if we run the app in the simulator we will not see any of our facts.
VALUES 39:02
We have taken care of a lot of the conversion from JSON file storage to SQLite storage, but we still aren’t performing any kind of query in order to fetch facts. We need to do this when the view first appears, which we can handle by using the task modifier to invoke an async method on the model: .task { await model.onTask() }
VALUES 39:40
And then in this onTask method we can make the SQLite query: func onTask() async { }
VALUES 39:50
But rather than just making a single query right when the view appears, which helps us get the facts a single time but doesn’t help us refresh the view if the facts ever change, we can instead use GRDB’s value observation tool: ValueObservation.tracking { db in }
VALUES 40:08
This allows you to execute a query to fetch data, and further if the database ever changes the query will be re-executed allowing you to get the freshest data.
VALUES 40:16
The simplest thing we could do in this tracking closure would be to simply fetch all facts: ValueObservation.tracking { [ordering] db in try Fact .all() .fetchAll(db) }
VALUES 40:26
But we can take things a step further. Remember that we have some ordering state that determines how we want the facts ordered. Let’s incorporate that into the query. To do that we can use the order method from GRDB that takes a variadic list of ordering terms: try Fact.all() .order(<#orderings: any SQLOrderingTerm...#>) .fetchAll(db)
VALUES 40:44
Ordering terms are things that describe how to order a query, such as which column to sort on, and which direction. We can add a computed property to our Ordering enum in order to derive a GRDB ordering term: enum Ordering: String, CaseIterable { … var orderingTerm: any SQLOrderingTerm { switch self { case .number: Column("number") case .savedAt: Column("savedAt").desc } } }
VALUES 41:32
And then we can use that ordering term to order the results we are fetching: ValueObservation.tracking { [ordering] db in try Fact.all() .order(ordering.orderingTerm) .fetchAll(db) }
VALUES 41:43
This constructs a ValueObservation value which we can subscribe to in order to get the results of the query whenever it is executed. Since we are already in an async context, the simplest way to do this is to get access to its async sequence: let sequence = ValueObservation.tracking { [ordering] db in try Fact.all() .order(ordering.orderingTerm) .fetchAll(db) } .values(in: database)
VALUES 42:16
And then we can iterate over the sequence so that when it emits we can update the state in our model: do { for try await facts in sequence { favoriteFacts = facts } } catch { reportIssue(error) }
VALUES 42:40
We are getting very close to a fully functional feature. When we launch the app in the simulator it does load and save some facts, and they are even ordered the way we expect automatically. But when we change the sort, nothing happens.
VALUES 43:17
And this is because currently when the sort is changed there is nothing to kick the app in the butt and perform the query again. There are a few ways we could approach this, but by far the easiest is to use the task(id:) view modifier so that Swift will cancel any in-flight work and restart the query when the ordering changes: .task(id: model.ordering) { await model.onTask() }
VALUES 43:48
That gets the job done. Now when we run the app in the simulator we can change the order and the facts will sort. And this sorting operation does not change the data held on disk at all. The data is stored in SQLite and we are constructing a query to fetch the data from the database in whatever shape is important for our view. Next time: Bridging SQLite to @Shared
VALUES 44:11
We now have a fully functioning app, and everything is powered by SQLite. We can insert new facts into the database and the view will immediately refresh with the newest data. And the same happens when removing a fact. Brandon
VALUES 44:21
However, there are a few things to not like about the code we have right now. First, we had to put some of our feature’s logic in the view. In particular, we are leveraging the task(id:) view modifier to listen for changes to state that cause the SQL query to change, and then at that moment we go tell the model to re-fetch the data from the database. That’s a very roundabout and indirect way to do things. Stephen
VALUES 44:43
Further, we have a mutable array of facts right in our model, but we aren’t meant to actually mutate it directly. In fact, doing so will not save the data in the database, and so will definitely lead to bugs in your code. You have to instead remember to make any mutations through the database connection, and then the observation we set up will take care of updating the array of facts. It kinda seems like the array of facts should be read-only if possible. Brandon
VALUES 45:07
And finally, there is a lot of boilerplate needed to get all of this set up correctly. Every feature that wants access to data in the database will need to: …use the task(id:) view modifier to listen for state changes that change the SQL query, then inside that modifier invoke a method on the model to re-execute the query, and then inside that method set up SQL observation so that changes to the database will update the state held in the model.
VALUES 45:32
That is quite a bit, and it will be easy to get wrong.
VALUES 45:35
Well, what if we told you that it is possible to hide all of this boilerplate behind a custom persistence strategy that can be used with the @Shared property wrapper. That will allow you to hold onto the collection of facts in your feature as if it’s a regular array, but secretly it is being powered by SQLite.
VALUES 45:54
Let’s take a look…next time! References Sharing Point-Free Instantly share state among your app’s features and external persistence layers, including user defaults, the file system, and more. https://github.com/pointfreeco/swift-sharing SQLite The SQLite home page https://www.sqlite.org