Swiftpack.co - mergesort/Boutique as Swift Package

Swiftpack.co is a collection of thousands of indexed Swift packages. Search packages.
See all packages published by mergesort.
mergesort/Boutique 2.0.0-rc-1
A simple but surprisingly fancy cache
⭐️ 397
🕓 1 week ago
iOS macOS
.package(url: "https://github.com/mergesort/Boutique.git", from: "2.0.0-rc-1")

Boutique Logo

A simple but surprisingly fancy data store and so much more

"I ripped out Core Data, this is the way it should work"

Josh Holtz

If you find Boutique valuable I would really appreciate it if you would consider helping sponsor my open source work, so I can continue to work on projects like Boutique to help developers like yourself.


If you're getting started with Boutique today, it is highly recommended that you point your Package.swift to the main branch. The update is feature complete with only minor or likely no changes expected. It will be officially released when I finish adding documentation to provide tutorials, best practices, and updated sample projects. While v2 is a huge upgrade, the API changes from 1.0 are minimal enough that it shouldn't be difficult to get started without updated docs in the mean time.

Boutique is a simple but powerful persistence library, and more. With its dual-layered memory + disk caching architecture Boutique provides a way to build apps that update in real time with full offline storage in only a few lines of code using an incredibly simple API. Boutique is built atop Bodega, and you can find a reference implementation of an app built atop the Model View Controller Store architecture in this repo which shows you how to make an offline-ready SwiftUI app in only a few lines of code. You can read more about the thinking behind the architecture in this blog post exploring the MVCS architecture.



Getting Started

Boutique only has one concept to understand, the Store. You may be familiar with the Store from Redux or The Composable Architecture, but unlike those frameworks you won't need to worry about interacting with Actions or Reducers. With this Store implementation all your data is cached on disk for you automatically, no additional code required. This allows you to build realtime updating apps with full offline support in an incredibly simple and straightforward manner.


Store

The entire surface area of the API for achieving full offline support and realtime model updates across your entire app is three methods, .add(), .remove(), and .removeAll().

// Create a Store ¹
let store = Store<Item>(
    storagePath: Store.documentsDirectory(appendingPathComponent: "Items"),
    cacheIdentifier: \.id
)

// Add an item to the Store ²
let coat = Item(name: "coat")
try await store.add(coat)

// Remove an item from the Store
try await store.remove(coat)

// Add two more items to the Store
let purse = Item(name: "purse")
let belt = Item(name: "belt")
try await store.add([purse, belt])

// You can read items directly
print(store.items) // Prints [coat, belt]

// Clear your store by removing all the items at once.
store.removeAll()

print(self.items) // Prints []

// Add an item to the store, removing all of the current items 
// from the in-memory and disk cache before saving the new item. ³
try await store.add([purse, belt], invalidationStrategy: .removeNone)
try await store.add(coat, invalidationStrategy: .removeAll)

print(store.items) // Prints [coat]

And if you're building a SwiftUI app you don't have to change a thing, Boutique was made for and with SwiftUI in mind.

// Since items is a @Published property 
// you can subscribe to any changes in realtime.
store.$items.sink({ items in
    print("Items was updated", items)
})

// Works great with SwiftUI out the box for more complex pipelines.
.onReceive(store.$items, perform: {
    self.allItems = $0.filter({ $0.id > 100 })
})

¹ You can have as many or as few Stores as you'd like. It may be a good strategy to have one Store for all of the images you download in your app, but you may also want to have one Store per model-type you'd like to cache. You can even create separate stores for tests, Boutique isn't prescriptive and the choice for how you'd like to model your data is yours.

² Under the hood the Store is doing the work of saving all changes to disk when you add or remove items.

³ There are multiple cache invalidation strategies. removeAll would be useful when you are downloading completely new data from the server and want to avoid a stale cache.

⁴ In SwiftUI you can even power your Views with $items and use .onReceive() to update and manipulate data published by the Store's $items.

Warning Storing images or other binary data in Boutique is technically supported but not recommended. The reason is that storing images in Boutique's can balloon up the in-memory store, and your app's memory as a result. For similar reasons as it's not recommended to store images or binary blobs in a database, it's not recommended to store images or binary blobs in Boutique.


The Magic of @Stored

That was easy, but I want to show you something that makes Boutique feel downright magical. The Store is a simple way to gain the benefits of offline storage and realtime updates, but by using the @Stored property wrapper we can cache any property in-memory and on disk with just one line of code.

final class ImagesController: ObservableObject {

    /// Creates a @Stored property to handle an in-memory and on-disk cache of images. ⁵
    @Stored(in: Store.imagesStore) var images

    /// Fetches `RemoteImage` from the API, providing the user with a red panda if the request succeeds.
    func fetchImage() async throws -> RemoteImage {
        // Hit the API that provides you a random image's metadata
        let imageURL = URL(string: "https://image.redpanda.club/random/json")!
        let randomImageRequest = URLRequest(url: imageURL)
        let (imageResponse, _) = try await URLSession.shared.data(for: randomImageRequest)

        return RemoteImage(createdAt: .now, url: imageResponse.url, width: imageResponse.width, height: imageResponse.height, imageData: imageResponse.imageData)
    }
  
    /// Saves an image to the `Store` in memory and on disk.
    func saveImage(image: RemoteImage) async throws {
        try await self.$images.add(image)
    }
  
    /// Removes one image from the `Store` in memory and on disk.
    func removeImage(image: RemoteImage) async throws {
        try await self.$images.remove(image)
    }
  
    /// Removes all of the images from the `Store` in memory and on disk.
    func clearAllImages() async throws {
        try await self.$images.removeAll()
    }

}

That's it, that's really it. It's hard to believe that now your app can update its state in real time with full offline storage thanks to only one line of code. @Stored(in: Store.imagesStore) var images


⁵ (If you'd prefer to decouple the store from your view model, controller, or manager object, you can inject stores into the object like this.)

final class ImagesController: ObservableObject {

    @Stored var images: [RemoteImage]

    init(store: Store<RemoteImage>) {
        self._images = Stored(in: store)
    }

}

Further Exploration

Boutique is very useful on its own for building realtime offline-ready apps with just a few lines of code, but it's made even more powerful by the Model View Controller Store architecture I've developed, demonstrated in the ImagesController above. MVCS brings together the familiarity and simplicity of the MVC architecture you know and love with the power of a Store, to give your app a simple but well-defined state management and data architecture.

If you'd like to learn more about how it works you can read about the philosophy in a blog post where I explore MVCS for SwiftUI, and you can find a reference implementation of an offline-ready realtime MVCS app powered by Boutique in this repo.


Feedback

This project provides multiple forms of delivering feedback to maintainers.

  • If you have a question about Boutique, we ask that you first consult the documentation to see if your question has been answered there.

  • This project is heavily documented but also includes multiple sample projects.

    • The first app is a Demo app which shows you how to build a canonical Boutique app using the Model View Controller Store pattern. The app is heavily documented with inline explanations to help you build an intuition for how a Boutique app works and save you time by teaching you best practices along the way.
    • The second app is a Performance Profiler also using Boutique's preferred architecture. If you're working on a custom StorageEngine this project will serve you well as a way to test the performance of the operations you need to build.
  • If you still have a question, enhancement, or a way to improve Boutique, this project leverages GitHub's Discussions feature.

  • If you find a bug and wish to report an issue would be appreciated.


Requirements

  • iOS 13.0+
  • macOS 11.0+
  • Xcode 13.2+

Installation

Swift Package Manager

The Swift Package Manager is a tool for automating the distribution of Swift code and is integrated into the Swift build system.

Once you have your Swift package set up, adding Boutique as a dependency is as easy as adding it to the dependencies value of your Package.swift.

dependencies: [
    .package(url: "https://github.com/mergesort/Boutique.git", .upToNextMajor(from: "1.0.0"))
]

Manually

If you prefer not to use SPM, you can integrate Boutique into your project manually by copying the files in.


About me

Hi, I'm Joe everywhere on the web, but especially on Twitter.

License

See the license for more information about how you can use Boutique.

Sponsorship

Boutique is a labor of love to help developers build better apps, making it easier for you to unlock your creativity and make something amazing for your yourself and your users. If you find Boutique valuable I would really appreciate it if you'd consider helping sponsor my open source work, so I can continue to work on projects like Boutique to help developers like yourself.

GitHub

link
Stars: 397
Last commit: 2 days ago
jonrohan Something's broken? Yell at me @ptrpavlik. Praise and feedback (and money) is also welcome.

Dependencies

Release Notes

Version 2.0 RC 1: Bring Your Own Database And More
1 week 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!

Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API | Analytics