Boutique

✨ A magical persistence library (and so much more) for state-driven iOS and Mac apps ✨

MIT License

Stars
919
Committers
10

Bot releases are hidden (Show)

Boutique - Ambiguity Shambiguity Latest Release

Published by mergesort 4 months ago

This release updates Boutique to Bodega 2.1.3, to resolve an ambiguous reference to Expression which was added to Foundation in iOS 18/macOS 15. Thank you @samalone for the help!

[!IMPORTANT]
This release contains an important fix and significant performance improvements. I would highly recommend updating your version of Boutique, especially if you're using the chained operations syntax.

Store

When using a chained operation it was possible for not all values to be removed properly, leading to the incorrect storage of extra data.

try await self.$items
    .remove(oldItems)
    .insert(newItems)
    .run()

More tests have been added to test all sorts of chaining scenarios to prevent this regression from occurring again.

SecurelyStoredValue

When you had a keychain value which existed but it's shape changed (such as adding or removing a property from a type), it was impossible to remove that value. Now the .remove() function will remove a value when it cannot properly decode the old value, allowing you to overwrite values when adding/removing properties or changing the underlying type of a SecurelyStoredValue.

StoredValue

An additional layer of caching has been added to StoredValue so that when you access a StoredValue it no longer has to decode JSON every time. This will still occur on an app's first load of that value, but future accesses come with significant performance improvements, especially for more complicated objects.

Boutique - *Don't* Remove All

Published by mergesort 9 months ago

[!IMPORTANT]
This release contains a crucial upgrade, please update your library.

This release fixes an bug in Boutique that could lead to data-loss in specific circumstances when chaining .remove() and .insert() using Boutique.

Boutique was exhibiting incorrect behavior when chaining the remove() function with an insert() after, due to an underlying implementation bug. The code below demonstrates how the bug would manifest.

// We start with self.items = [1, 2, 3, 4, 5, 6, 7, 8]

// An API call is made and we receive [1, 2, 3, 8, 9, 10] to be inserted into to self.items.
// We pass that `updatedItems` array into an `update` function that removes any items that need to be removed, and then inserts the newly updated items.

func update(_ updatedItems: [Int]) async throws {
    let items = self.items.filter({ updatedItems.contains($0) })

    try await self.$items
        .remove(items)
        .insert(updatedItems)
        .run()
}

// `self.items` now should be [1, 2, 3, 4, 5, 6, 7, 8] 
// `self.items` is actually [10] 

There was an assumption built into how chained operations work, based on how Boutique was being used in the early days of the library.

Internally Boutique has two ItemRemovalStrategy properties, .removeAll which removes all the items by deleting the underlying table, and removeItems(items) to remove a specific set of items. Unfortunately due to a logic error .removeAll would be called whenever the amount of items to remove matched the amount of items that were being inserted in a chain, which is not always the developer's intention. That would delete the underlying data and insert the last item, leaving users with only one item.

My sincerest apologies for this bug, and since this pattern is not necessarily common I hope that it has not affected many users.

Boutique - Your Presence Is Requested

Published by mergesort 10 months ago

StoredValue and AsyncStoredValue have a new API when the Item stored is an Array.

The new togglePresence function is a handy little shortcut to insert or remove an item from a StoredValue (or AsyncStoredValue) based on whether the currently StoredValue already contains that value.

It's very simple to use.

self.$redPandas.togglePresence(.pabu)
  1. If pabu isn't in the array of red pandas then Pabu will be inserted.
  2. If pabu is in the array of red pandas then Pabu will be removed.

Why add this function? I found myself reaching for a function of this shape when interacting with stateful interfaces in SwiftUI, and thought it would make your life easier as it's made mine. 🦊

Boutique - At Your Service (And Group)

Published by mergesort about 1 year ago

Boutique's SecurelyStoredValue is meant to be a simple layer to over a whole complex set of keychain APIs to build a simple solution for simple use cases. Occasionally a little additional complexity is valuable though, complexity that allows for more powerful use cases.

This release provides two new properties when initializing a SecurelyStoredValue, group and service. These two properties represent a Keychain's group and a Keychain's service, which control how and where data is stored in the system Keychain. The group and service properties are of types KeychainGroup and KeychainService respectively.

[!NOTE]
Previously no group was ever set, and the service always mapped to Bundle.main.bundleIdentifier. This made it so values could not be shared between two targets (for example an app and a widget). The same SecurelyStoredValue would have a different bundle identifier based on where the value was being accessed, and would return no value for one target's valid keychain entry.

The group and service properties are optional so you can keep your code the same way it was before.

@SecurelyStoredValue<AuthToken>(key: "authToken")

Or if you'd like to share a value across targets, you can use the group or service parameters, or both together.

@SecurelyStoredValue<AuthToken>(key: "authToken", group: keychainGroup)
@SecurelyStoredValue<AuthToken>(key: "authToken", service: keychainService)
@SecurelyStoredValue<AuthToken>(key: "authToken", service: keychainService, group: keychainGroup)

Both KeychainGroup and KeychainService conform to ExpressibleByStringLiteral, so you can also use a string in place of these types.

@SecurelyStoredValue<AuthToken>(key: "authToken", service: "com.boutique.service", group: "com.boutique.group")

Now let's go be more secure than ever!

Boutique - No YOU'RE Insecure

Published by mergesort about 1 year ago

This is a big release, adding a new @SecurelyStoredValue property wrapper to make Boutique a one stop shop for all your persistence needs.

The @SecurelyStoredValue property wrapper can do everything a @StoredValue does, but instead of persisting values in UserDefaults a @SecurelyStoredValue will save items in the system's Keychain. This is perfect for storing sensitive values such as passwords or auth tokens, which you would not want to store in UserDefaults.


Using a SecurelyStoredValue is drop dead simple. Declare the property:

@SecurelyStoredValue<String>(key: "authToken")
private var authToken

Set a value:

$authToken.set("super_secret_p@ssw0rd")

And now it's ready to use anywhere you need.

self.apiController.authenticatedAPICall(withToken: self.authToken)

Breaking change:

  • One small API update in this release, @StoredValue's set and reset functions are now bound to the @MainActor. This is to prevent race conditions that could occur when attempting to modify StoredValue's publisher property.
Boutique - Self-Evident Truths: All Stores Are Created Equal

Published by mergesort about 1 year ago

This release makes a few subtle improvements to improve some of Boutique's ergonomics and potential race conditions.

  • Removing the Equatable constraint on a Store's Item, now all Item has to conform to is Codable.
    • Thank you @connor-ricks so much for #53!
  • Adding a do/catch in loadStoreTask to make debugging Store load failures easier. This isn't strictly necessary but I found myself doing this often when I couldn't figure out why a Store was throwing an error, and thought it might be helpful to expose.
  • StoredValue is now bound to @MainActor, which is more in line with expectations.
    • This also addresses any potential race conditions where the publisher property could emit at a different time than the underlying change to UserDefaults occurred.
Boutique - Wait Up A Second…

Published by mergesort over 1 year ago

The highlight of this release a new async initializer for a Boutique Store, thanks to the contribution of @rl-pavel. This initializer solves two problems.

  1. Previously when an app was starting up you would have to wait for a Store to finish loading before moving onto your next task, ostensibly acting as a blocking procedure. The Store was fast so it was not very noticeable from a performance perspective, but depending on the state-driven interface you were constructing and how big your Store was, it could be noticeable.
  2. The main problem this caused was not being able to tell whether the items in your Store still hadn't loaded, or if they had loaded with zero items. I call this the empty state problem, where you would see your empty state screen displayed for a split second, and then your items would load into place. This was a suboptimal experience, but is now a thing of the past.

You shouldn't notice any changes when using the Store's initializer, but you will now have this fancy method that shows you if the store has finished loading.

await store.itemsHaveLoaded()

What this allows you to do is to drive a SwiftUI/UIKit/AppKit view based on the Store's state. A simplified example looks like this.

struct ItemListView: View {
    @State private var itemsHaveLoaded = false

    var body: some View {
        VStack {
            AlwaysVisibleBanner()

            if self.itemsHaveLoaded {
                if self.items.isEmpty {
                    EmptyStateView()
                } else {
                    ItemView(items: self.items)
                }
            } else {
                LoadingStateView()
            }
        }
        .task({
            try await self.itemsController.items.itemsHaveLoaded()
            self.itemsHaveLoaded = true
        })
    }
}

This is a a really readable solution to a tricky problem, so once again, thank you Pavel. 👏🏻


Breaking Changes

  • StoredValue.binding is now computed property rather than a StoredValue.binding() function.
  • I've added back the Store.Operation.add functions which allowed for chained operations, they were accidentally marked as deprecated, oops.
Boutique - Little Touches

Published by mergesort almost 2 years ago

The work never stops at the Boutique! One touch up, and one oops to fix. (My bad…)

  • In 2.0.4 I added a binding() function on @StoredValue and @AsyncStoredValue, it's now a computed property.
  • In 2.1.0 I accidentally removed the add function from Store.Operation, now it's back. It will be deprecated later, not now.
Boutique - Goodbye Add, Hello Insert

Published by mergesort almost 2 years ago

As discussed in #36, the Store's add function can be a little bit ambiguously named. When you call add on the Store it will either add an item or update an existing item, if that item already exists in the `Store.

Naming this function add makes sense if you think of the Store as a bag of items that you can add or remove items from, but when an update occurs, the name is no longer as obvious. Having had a few months to use Boutique in production I've come to believe that insert is a better and less ambiguous name than add. The cool thing about being the benevolent dictator of Boutique is that I can decide to treat the Store like a set if the Store is going to act like a Set. (I also consulted with many developers to get their feedback, I'm not some kind of monster.)

From this day forward add will be renamed insert, to match how Swift's naming convention in Set. The functionality of the add and insert are identical, which means that add will continue to work for some time going forward (with a warning in Xcode), and migrating to insert will change none of your app's functionality.

Even though you don't have to migrate yet, migrating will be as simple as changing one line of code.

// Before
store.add(item)

// After
store.insert(item)

This process will be made even easier by providing an Xcode fixit in the deprecation warning that the user can click to change the function name on their behalf. And don't worry, the add function will continue working as it has until it is fully removed.

Sincerely,
Your benevolent dictator (of Boutique) 👑

Boutique - Can You Help? I'm In A Bit Of A Bind...ing

Published by mergesort almost 2 years ago

Do you use SwiftUI? Cool, me too, and boy are there a lot of Bindings. This release includes a small improvement for @StoredValue and @AsyncStoredValue, allowing you to create a binding from a value backed by either of our property wrappers.

Before you would have to write out this isEnabled Binding manually.

Checkbox(
    title: "Red Panda Button",
    description: "They're the best, aren't they? Press this button to enable some red pandas.",
    isEnabled: Binding(get: {
        self.preferences.isRedPandaModeEnabled
    }, set: {
        self.preferences.$isRedPandaModeEnabled.set($0)
    })
)

But now, automagically generated for you with the power of functions.

Checkbox(
    title: "Red Panda Button",
    description: "They're the best, aren't they? Press this button to enable some red pandas.",
    isEnabled: self.preferences.$isRedPandaModeEnabled.binding()
)

How nice is that? @StoredValue and @AsyncStoredValue are more powerful than ever.

Boutique - All The Best Words Are In The Dictionary

Published by mergesort almost 2 years ago

This release includes a small improvement for @StoredValue and @AsyncStoredValue when those values are of the type Dictionary.

Previously when you stored a Dictionary and wanted to update it, you would have to make a mutable copy of current dictionary, change the value, and then save it back. To make this a little easier Boutique 2.0.3 introduces an update(key:value) function that turns this into one line of code.

A small example to illustrate the improvement

Before

var updatedRedPandaList = self.redPandaList
updatedRedPandaList["best"] = "Pabu"
self.$redPandaList.set(updatedRedPandaList)

After!

self.$redPandaList.update(key: "best", value: "Pabu")

So much cleaner, enjoy!

Boutique - Never Feel Empty Using Boutique Again

Published by mergesort about 2 years ago

This release includes a small fix for Stores that use SQLiteStorageEngine. Previously they would throw an error if you tried to save an empty array, such as this.

let noItems = [] // You can imagine some computation that ends up with an empty array rather than setting [] directly
storageEngine.write(noItems)

Now it will not throw an error, instead the write function will return early and act as a no-op, more accurately matching a user's expectations.

See: Bodega 2.0.2

Boutique - It's The Little Things

Published by mergesort about 2 years ago

This release adds a Store initializer for when Item conforms Identifiable and the id is a UUID.

Previously your initializer would look like this.

let store = Store<Item>(storage: storage, cacheIdentifier: \.id.uuidString)

The latter parameter is unnecessary though, we can instead infer when you have a UUID that the cacheIdentifier will always be \id.uuidString. Now your initializer will be a little simpler and skip the cacheIdentifier parameter.

let store = Store<Item>(storage: storage)

That’s it, have a slightly simpler Boutique!

Boutique - Version 2.0: Bring Your Own Database And So Much More

Published by mergesort about 2 years ago

This update's a big one!

This version update isn't just one version update, it's two. Boutique's 2.0 depends on Bodega version 2.0, which is a huge update in its own right. Boutique's Bring Your Own Database feature is powered by Bodega, which means you get all of the functionality with no API changes to Boutique. And of course it's still only a couple of lines of code to have an app with a single source of truth, realtime updates, the offline support you've come to know and love, now 5-10x faster out of the box.


Warning
This version contains breaking changes


@StoredValue & AsyncStoredValue

Most data your app works with is in the shape of an array, but sometimes you need to store a single value. That's what @StoredValue is for. As the name implies @StoredValue allows you to store a value, which is great for saving user preferences, configurations, or even individual value like lastOpenedDate. AsyncStoredValue has the exact same API, but allows you to bring your own StorageEngine rather than depending on UserDefaults under the hood.

Creating a @StoredValue is easy, it even supports default values like you would expect with any other Swift property.

@StoredValue(key: "pandaRojo")
private var spanishRedPanda = RedPanda(cuteRating: 100)

A more complex example may look like this, for example if you were building a Youtube-like video app.

struct UserPreferences: Codable, Equatable {
    var hasProvidedNotificationsAccess: Bool
    var hasHapticsEnabled: Bool
    var prefersDarkMode: Bool
    var prefersWideScreenVideos: Bool
    var spatialAudioEnabled: Bool
}

struct UserPreferences: Codable, Equatable {
    var hasProvidedNotificationsAccess: Bool
    var hasHapticsEnabled: Bool
    var prefersDarkMode: Bool
    var prefersWideScreenVideos: Bool
    var spatialAudioEnabled: Bool
}

struct LikedVideos: Codable, Equatable {
    let ids: [Int]
}

struct DownloadedVideos: Codable, Equatable {
    let ids: [Int]
}

struct AppState {

    @StoredValue<UserPreferences>(key: "userPreferences")
    var preferences = UserPreferences()

    @StoredValue(key: "likedEpisodes")
    var likedVideos = LikedVideos(ids: [1, 2, 3])

    @StoredValue<DownloadedVideos>(key: "downloadedVideos")
    var downloadedVideos = DownloadedVideos(ids: [])

    @StoredValue(key: "openLinksInSafari")
    var openLinksInSafari = true

}

Thank you to @iankeen for helping me build @StoredValue and @AsyncStoredValue, and working through some nuances as the final version took shape.


AppKit/UIKit support

This one does what it says on the tin, Boutique is no longer constrained to SwiftUI. @Stored and the new @StoredValue/@AsyncStoredValue will work in UIKit and AppKit apps!


Chained Operations

This is a breaking change, but a very worthwhile one. Previously when you added an item there was an removingExistingItems parameter that would provide a form of cache invalidation. But as they say, the two hardest problems in computer science are naming, cache invalidation, and off by one errors, so let's fix all three in one fell swoop.

What used to look like this

public func add(_ item: Item, removingExistingItems existingItemsStrategy: ItemRemovalStrategy<Item>? = nil) async throws

Now becomes much simpler

public func add(_ item: Item) async throws -> Operation

The reason for the removingExistingItems parameter was to remove cached items and add new items in one operation, preventing multiple dispatches to the @MainActor. We wanted to avoid multiple dispatches to avoid multiple SwiftUI render cycles, and now we can avoid that thanks to Operation chaining. But what is Operation? An Operation is a type you never have to think about, but it allows us to chain commands together transparently, like this.

self.store
    .removeAll()
    .add(items: [1, 2, 3])
    .run()
// The Store now contains [1, 2, 3]

self.store
    .remove(1)
    .add(items: [4, 5, 6])
    .run()
// The Store now contains [2, 3, 4, 5, 6]

This fluent syntax is much more intuitive, and allows us to remove the confusing removingItems parameter from .add(items: [Item], removingItems: ItemRemovalStrategy). Previously adding items and cache invalidation were conflated, thanks to some type system fun we were able to improve this syntax, without SwiftUI rendering issues.

Thank you to @davedelong for helping me think through and prototyping chained operations, I really appreciate what came to be and wouldn't have gotten there without his help.


defaultStorageDirectory

Previously the default folder location a Store was initialized was the Documents directory. This makes sense on iOS, tvOS, and more locked down platforms, but on macOS it makes more sense to store data in the Application Support folder. Support for defaultStorageDirectory comes from Bodega, but if you're initializing a Boutique Store the location will now default to the expected folder on each platform.


Bring Your Own Database

In the Version 1.x series of Bodega the DiskStorage type was responsible for persisting data to disk. As the name implies DiskStorage was backed by the file system, but what if you don't want to save Data to disk? Saving data to disk is a simple and effective starting point, but can get slow when working with large data sets. One of Bodega's goals is to work with every app without causing developers to make tradeoffs, so version 2.0 is focused on eliminating those tradeoffs without ruining the streamlined simplicity Bodega brings, and brings that to Boutique.

In the spirit of not making tradeoffs here's how Bodega works with any database you want, say hello to the new StorageEngine protocol.

public protocol StorageEngine: Actor {
    func write(_ data: Data, key: CacheKey) async throws
    func write(_ dataAndKeys: [(key: CacheKey, data: Data)]) async throws

    func read(key: CacheKey) async -> Data?
    func read(keys: [CacheKey]) async -> [Data]
    func readDataAndKeys(keys: [CacheKey]) async -> [(key: CacheKey, data: Data)]
    func readAllData() async -> [Data]
    func readAllDataAndKeys() async -> [(key: CacheKey, data: Data)]

    func remove(key: CacheKey) async throws
    func remove(keys: [CacheKey]) async throws
    func removeAllData() async throws

    func keyExists(_ key: CacheKey) async -> Bool
    func keyCount() async -> Int
    func allKeys() async -> [CacheKey]

    func createdAt(key: CacheKey) async -> Date?
    func updatedAt(key: CacheKey) async -> Date?
}

By providing your own write, read, remove, key, and timestamp related functions, you can make any persistence layer compatible with ObjectStorage. Whether your app is backed by Realm, Core Data, or even CloudKit, when you create a new StorageEngine it automatically becomes usable by Boutique's Store, no new APIs to learn.

The first StorageEngine to be implemented is an SQLiteStorageEngine, bundled with Bodega. I'll explain all the possibilities below, but first let's take a second to see how much faster your apps using Bodega and Boutique will be.

StorageEngine Read Performance
StorageEngine Write Performance


If it's not obvious, a SQLite foundation for Bodega is tremendously faster than using the file system as we did in Boutique v1. The DiskStorageStorageEngine is still available, but if you use the SQLiteStorageEngine loading 10,000 objects into memory will be more than 400% faster, and writing 5,000 objects is more than 500% faster. With this release I feel confident that you should be able to use Bodega and Boutique in the largest of apps, while counterintuitively becoming a more flexible framework.


Breaking

Now that you can provide a StorageEngine the Store initializer goes from this

let animalsStore = Store<Animal>(
    storagePath: Store<Animal>.documentsDirectory(appendingPath: "Animals"),
    cacheIdentifier: \.id
)

To this

let animalsStore = Store<Animal>(
    storage: SQLiteStorageEngine(directory: .defaultStorageDirectory(appendingPath: "Animals")),
    cacheIdentifier: \.id
)

Or even simpler if you use the new default SQLiteStorageEngine in the default Data database.

let animalsStore = Store<Animal>(cacheIdentifier: \.id)

For a backwards compatible StorageEngine you can use the new DiskStorageEngine, which was powering your data in v1.

let animalsStore = Store<Animal>(
    storage: DiskStorageEngine(directory: .defaultStorageDirectory(appendingPath: "Animals")),
    cacheIdentifier: \.id
)

You can find Boutique's documentation here, including a lot of updates for the v2 release. The demo app has been updated to account for the API updates, and now there's a Performance Profiler app to help you build custom a StorageEngine.

P.S. If you build something useful to others, by all means file a pull request so I can add it to Boutique!

Boutique - Version 2.0 RC 2: Bring Your Own Database And More

Published by mergesort about 2 years ago

If you'd like to see all of the v2 changes please consult the v2 RC 1 release notes.

RC 2 Changes

  • Boutique 2.0.0-rc2 specifies Bodega 2.0.0-rc2 as a dependency.
  • Adding a Boutique demo project.
  • The Store now provides a static function to use in SwiftUI previews.
static func previewStore(items: [Item], cacheIdentifier: KeyPath<Item, String>) -> Store<Item> 

StoredValue and AsyncStoredValue

  • The type previously called StoredValue which persists data in a StorageEngine has become a new type, AsyncStoredValue.
  • There is a new StoredValue type which persists data in UserDefaults, allowing you to have the values available on demand rather than loading asynchronously when the StorageEngine provides the data.
  • Specifying a default value for a StoredValue was optional before, it is now required.
  • Adding a toggle() function to flip the state of a StoredValue or AsyncStoredValue that is storing a boolean.
Boutique - Version 2.0 RC 1: Bring Your Own Database And More

Published by mergesort about 2 years ago

This update's a big one!

This version update isn't just one version update, it's two. Boutique's 2.0 depends on Bodega version 2.0, which is a huge update in its own right. The Bring Your Own Database feature is powered by Bodega, which means you get all of the functionality with no API changes to Boutique. And of course it's still only a couple of lines of code to have an app with a single source of truth, realtime updates, the offline support you've come to know and love, and now 5-10x faster out of the box.

But before we talk about the database, let's see what else Boutique 2.0 has to offer.


Warning
This version contains breaking changes


@StoredValue

Most data your app works with is in the shape of an array, but sometimes you need to store a single value. That's what @StoredValue is for. As the name implies @StoredValue allows you to store a value, which is great for saving user preferences, configurations, or even individual value like lastOpenedDate.

Creating a @StoredValue is easy, it even supports default values like you would expect with any other Swift property.

@StoredValue<RedPanda>(key: "pandaRojo")
private var spanishRedPanda = RedPanda(cuteRating: 100)

A more complex example may look like this, for example if you were building a Youtube-like video app.

struct UserPreferences: Codable, Equatable {
    var hasProvidedNotificationsAccess: Bool
    var hasHapticsEnabled: Bool
    var prefersDarkMode: Bool
    var prefersWideScreenVideos: Bool
    var spatialAudioEnabled: Bool
}

struct UserPreferences: Codable, Equatable {
    var hasProvidedNotificationsAccess: Bool
    var hasHapticsEnabled: Bool
    var prefersDarkMode: Bool
    var prefersWideScreenVideos: Bool
    var spatialAudioEnabled: Bool
}

struct LikedVideos: Codable, Equatable {
    let ids: [Int]
}

struct DownloadedVideos: Codable, Equatable {
    let ids: [Int]
}

struct AppState {

    @StoredValue<UserPreferences>(key: "userPreferences")
    var preferences

    @StoredValue(key: "likedEpisodes")
    var likedVideos = LikedVideos(ids: [1, 2, 3])

    @StoredValue<DownloadedVideos>(key: "downloadedVideos")
    var downloadedVideos

    @StoredValue(key: "openLinksInSafari")
    var openLinksInSafari = true

}

Thank you to @iankeen for helping me iterate on @StoredValue, and working through some nuances as the final version took shape.


AppKit/UIKit support

This one does what it says on the tin, Boutique is no longer constrained to SwiftUI. @Stored and the new @StoredValue will work in UIKit and AppKit apps!


Chained Operations

This is a breaking change, but a very worthwhile one. Previously when you added an item there was an removingExistingItems parameter that would provide a form of cache invalidation. But as they say, the two hardest problems in computer science are naming, cache invalidation, and off by one errors, so let's fix all three in one fell swoop.

What used to look like this

public func add(_ item: Item, removingExistingItems existingItemsStrategy: ItemRemovalStrategy<Item>? = nil) async throws

Now becomes much simpler

public func add(_ item: Item) async throws -> Operation

The reason for the removingExistingItems parameter was to remove cached items and add new items in one operation, preventing multiple dispatches to the @MainActor. We wanted to avoid multiple dispatches to avoid multiple SwiftUI render cycles, and now we can avoid that thanks to Operation chaining. But what is Operation? An Operation is a type you never have to think about, but it allows us to chain commands together transparently, like this.

self.store.removeAll().add(items: [1, 2, 3]).run() // The Store now contains [1, 2, 3]
self.store.remove(1).add(items: [4, 5, 6]).run() // The Store now contains [2, 3, 4, 5, 6]

This fluent syntax is much more intuitive, and no longer do you have a confusing parameter that conflates cache invalidation and adding items due to an unexpected side effect of how SwiftUI renders occur.

Thank you to @davedelong for helping me think through and prototyping chained operations, I really appreciate what came to be and wouldn't have gotten there without his help.


defaultStorageDirectory

Previously the default folder location a Store was initialized was the Documents directory. This makes sense on iOS, tvOS, and more locked down platforms, but on macOS it makes more sense to store data in the Application Support folder. Support for defaultStorageDirectory comes from Bodega, but if you're initializing a Boutique Store the location will now default to the expected folder on each platform.


Bring Your Own Database

In the Version 1.x series of Bodega the DiskStorage type was responsible for persisting data to disk. As the name implies DiskStorage was backed by the file system, but what if you don't want to save Data to disk? Saving data to disk is a simple and effective starting point, but can get slow when working with large data sets. One of Bodega's goals is to work with every app without causing developers to make tradeoffs, so version 2.0 is focused on eliminating those tradeoffs without ruining the streamlined simplicity Bodega brings, and brings that to Boutique.

In the spirit of not making tradeoffs here's how Bodega works with any database you want, say hello to the new StorageEngine protocol.

public protocol StorageEngine: Actor {
    func write(_ data: Data, key: CacheKey) async throws
    func write(_ dataAndKeys: [(key: CacheKey, data: Data)]) async throws

    func read(key: CacheKey) async -> Data?
    func read(keys: [CacheKey]) async -> [Data]
    func readDataAndKeys(keys: [CacheKey]) async -> [(key: CacheKey, data: Data)]
    func readAllData() async -> [Data]
    func readAllDataAndKeys() async -> [(key: CacheKey, data: Data)]

    func remove(key: CacheKey) async throws
    func remove(keys: [CacheKey]) async throws
    func removeAllData() async throws

    func keyExists(_ key: CacheKey) async -> Bool
    func keyCount() async -> Int
    func allKeys() async -> [CacheKey]

    func createdAt(key: CacheKey) async -> Date?
    func updatedAt(key: CacheKey) async -> Date?
}

By providing your own write, read, remove, key, and timestamp related functions, you can make any persistence layer compatible with ObjectStorage. Whether your app is backed by Realm, Core Data, or even CloudKit, when you create a new StorageEngine it automatically becomes usable by ObjectStorage, with one drop dead simple API.

The first StorageEngine to be implemented is an SQLiteStorageEngine, bundled with Bodega. I'll explain all the possibilities below, but first let's take a second to see how much faster your apps using Bodega and Boutique will be.

StorageEngine Read Performance
StorageEngine Write Performance


If it's not obvious, a SQLite foundation for Bodega is incredibly faster than using the file system. The DiskStorageStorageEngine is still available, but if you use the SQLiteStorageEngine loading 10,000 objects into memory will be more than 400% faster, and writing 5,000 objects is more than 500% faster. With this release I feel confident that you should be able to use Bodega and Boutique in the largest of apps, while counterintuitively becoming a more flexible framework.


Breaking

Now that you can provide a StorageEngine the Store initializer goes from this

let animalsStore = Store<Animal>(
        storagePath: Store<Animal>.documentsDirectory(appendingPath: "Animals"),
        cacheIdentifier: \.id
    )

To this

let animalsStore = Store<Animal>(
    storage: SQLiteStorageEngine(directory: .defaultStorageDirectory(appendingPath: "Animals")),
    cacheIdentifier: \.id
)

Or even simpler if you use the new default SQLiteStorageEngine in the default Data database.

let animalsStore = Store<Animal>(cacheIdentifier: \.id)

For a backwards compatible StorageEngine you can use the new DiskStorageEngine, which was powering your data in v1.

let animalsStore = Store<Animal>(
    storage: DiskStorageEngine(directory: .defaultStorageDirectory(appendingPath: "Animals")),
    cacheIdentifier: \.id
)

P.S. If you build something useful to others, by all means file a pull request so I can add it to Boutique!

Boutique - Same Idea, New Name

Published by mergesort about 2 years ago

Warning
This version contains breaking changes


CacheInvalidationStrategy changes

  • CacheInvalidationStrategy has been renamed ItemRemovalStrategy. Even though "cache invalidation" is a common term of art, this more accurately reflects that the Store isn't a cache.
  • The second parameter in add(_: strategy:) has been renamed to add(_: existingItemsStrategy).
  • The second parameter now defaults to nil rather than .removeNone, and .removeNone has been removed since nil represents it equally well.
- public func add(_ items: [Item], invalidationStrategy strategy: CacheInvalidationStrategy<Item> = .removeNone) async throws
+ public func add(_ items: [Item], removingExistingItems existingItemsStrategy: ItemRemovalStrategy<Item>? = nil) async throws

These changes capitalize on the new ItemRemovalStrategy values introduced in v1.0.3.


  • Boutique now depends on Bodega 1.1.1.
Boutique - Invalidate Your Cache

Published by mergesort about 2 years ago

CacheInvalidationStrategy is now a struct rather than an enum.

There are no API changes in this release, but now you can create your own CacheInvalidationStrategys. This will allow you to decide what items you want to evict from the Store before adding a new object with additional clarity, now not depending only on the predefined options Boutique has provided.

Unchanged are:

  • .removeNone
  • .removeAll
  • .remove(items itemsToRemove: [Item])

There is now an additional default strategy:

  • .remove(where predicate: @escaping (Item) -> Bool)

This option allows you to pass in a predicate query, filtering out the current data in the Store.


Constructing your own removal strategy is easy, if you need inspiration here's a policy that will remove all animals that aren't household pets.

private extension Store.CacheInvalidationStrategy {
   static var removeAllWildAnimals: Store.CacheInvalidationStrategy<BoutiqueItem> {
       return CacheInvalidationStrategy(
           invalidatedItems: { items in
               items.filter({ $0.name == "dog" || $0.name == "cat" || $0.name == "hedgehog" })
           }
       )
   }
}

A special thank you to @iankeen for the inspiration, help, and background work in making this concept a reality.

Boutique - Want Some Speed? Hop Into A Corvette…

Published by mergesort over 2 years ago

Another minor version bump that does a lot more than you'd think for Boutique users.

  • Under the hood the add(items:) function was performing some rudimentary diffing to deduplicate items a user may add. That rudimentary diffing had very poor performance characteristics, and the more items you added the longer it took, because it had to iterate through the whole array. Now though we're using a neat trick, OrderedDictionary from the Swift Collections package. OrderedDictionary has performance characteristics of an array and a Dictionary, so now instead of taking upwards of 1.9 seconds to add the first 1,000 items into a Store (at once), it takes approximately 0.3 seconds. Adding one item is near instantaneous, and no matter the Store size, there now is no longer a penalty based on how many items you have in the Store.

In short, this is neat as heck! You can now add not only thousands of items without worrying, you can easily add 10s of thousands of items without issue, if not more.

  • But how do I know that the add(items:) function is so much faster? Well there's now an app for profiling performance, you can find it in the Performance Profiler folder.

As I was building Boutique it became apparent to me that I needed a way to see the effects of changes I was making in a more real-world manner than using unit tests to measure performance changes. This performance profiler app helps measure those changes and allows me to see the outcome of any changes I make, and pinpoint performance hotspots. It also allows you the user, anyone who's interested in using Boutique, to see what kind of performance they can expect in their apps.


There are no API changes so none of your code has to change!