Code Monkey home page Code Monkey logo

bodega's Introduction

Bodega Logo

An actor-based data layer, helping you build simple or complex stores for any iOS or Mac app. ๐Ÿช

If you find Bodega 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 Bodega to help developers like yourself.


As a born and raised New Yorker I can attest that bodegas act as humble infrastructure for our city, and Bodega aims to do that as well. We appreciate what bodegas do for us, yet it's their simplicity and prevalence that almost makes us forget they're here.

Bodega is an actor-based library that started as a simple cache based on reading and writing files to/from disk with an incredibly simple API. Today Bodega offers a form of infrastructure that any app's data layer can use. Whether you want to store Codable objects with ease, build caches, or interface with your API or services like CloudKit, it all works in just a few lines of code.

Bodega's StorageEngine is at the heart of what's possible. Conforming any database, persistence layer, or even an API server, to the StorageEngine protocol automatically provides an incredibly simple data layer for your app thanks to Bodega's ObjectStorage. Rather than Data and databases developers interact with their app's Swift types no matter what those may be, have a unified API, and concurrency handled out of the box.

Bodega is fully usable and useful on its own, but it's also the foundation of Boutique. You can find a demo app built atop Boutique in the Boutique Demo folder, showing you how to make an offline-ready realtime updating SwiftUI app in only a few lines of code. You can read more about the thinking behind the architecture in this blog post exploring Boutique and the Model View Controller Store architecture.



Getting Started

Bodega provides two types of storage primitives for you, StorageEngine and ObjectStorage. A StorageEngine writes Data to a persistence layer, while ObjectStorage works with Swift types that conform to Codable. A StorageEngine can save items to disk, SQLite, or even your own database, while ObjectStorage offers a unified layer over StorageEngines, providing a single API for saving objects to any StorageEngine you choose. Bodega offers DiskStorageEngine and SQLiteStorageEngine by default, or you can even build a StorageEngine based on your app's server or a service like CloudKit if you want a simple way to interface with your API. You can even compose storage engines to create a complex data pipeline that hits your API and saves items into a database, all in one API call. The possibilities are endless!


StorageEngines

// Initialize a SQLiteStorageEngine to save data to an SQLite database.
let storage = SQLiteStorageEngine(
    directory: .documents(appendingPath: "Quotes")
)

// Alternatively Bodega provides a DiskStorageEngine out of the box too.
// It has the same API but uses the file system to store objects. ยน
let storage = DiskStorageEngine(
    directory: .documents(appendingPath: "Quotes")
)

// CacheKeys can be generated from a String or URL.
// URLs will be reformatted into a file system safe format before writing to disk.
let url = URL(string: "https://redpanda.club/dope-quotes/dolly-parton")
let cacheKey = CacheKey(url: url)
let data = Data("Find out who you are. And do it on purpose. - Dolly Parton".utf8)

// Write data to disk
try await storage.write(data, key: cacheKey)

// Read data from disk
let readData = await storage.read(key: cacheKey)

// Remove data from disk
try await storage.remove(key: Self.testCacheKey)

ยน The tradeoffs of SQLiteStorageEngine vs. DiskStorageEngine are discussed in the StorageEngine documentation, but SQLiteStorageEngine is the suggested default because of it's far superior performance, using the same simple API.

Bodega provides two different instances of StorageEngine out of the box, but if you want to build your own all you have to do is conform to the StorageEngine protocol. This will allow you to create a StorageEngine for any data layer, whether you want to build a CoreDataStorageEngine, a RealmStorageEngine, a KeychainStorageEngine, or even a StorageEngine that maps to your API. If you can read, write, or delete data, you can conform to StorageEngine.

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?
}

ObjectStorage

Bodega is most commonly paired with Boutique, but you can also use it as a standalone cache. Any StorageEngine can read or write Data from your persistence layer, but ObjectStorage provides the ability to work with Swift types, as long as they conform to Codable. ObjectStorage has a very similar API to DiskStorage, but with slightly different function names to be more explicit that you're working with objects and not Data.

// Initialize an ObjectStorage object
let storage = ObjectStorage(
    storage: SQLiteStorageEngine(directory: . documents(appendingPath: "Quotes"))!
)

let cacheKey = CacheKey("churchill-optimisim")

let quote = Quote(
    id: "winston-churchill-1",
    text: "I am an optimist. It does not seem too much use being anything else.",
    author: "Winston Churchill",
    url: URL(string: "https://redpanda.club/dope-quotes/winston-churchill")
)

// Store an object
try await storage.store(quote, forKey: cacheKey)

// Read an object
let readObject: Quote? = await storage.object(forKey: cacheKey)

// Grab all the keys, which at this point will be one key, `cacheKey`.
let allKeys = await storage.allKeys()

// Verify by calling `keyCount`, both key-related methods are also available on `DiskStorage`.
await storage.keyCount()

// Remove an object
try await storage.removeObject(forKey: cacheKey)

Further Exploration

Bodega is very useful as a primitive for interacting with and persisting data, but it's even more powerful when integrated into Boutique. Boutique is a Store and serves as the foundation of the complementing Model View Controller Store (MVCS) architecture. 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 updating 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 Bodega, we ask that you first consult the documentation to see if your question has been answered there.

  • If you still have a question, enhancement, or a way to improve Bodega, 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 Bodega as a dependency is as easy as adding it to the dependencies value of your Package.swift.

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

Manually

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


About me

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

License

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

Sponsorship

Bodega 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 Bodega 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 Bodega to help developers like yourself.


Now that you're up to speed, let's take this offline ๐Ÿ“ญ

bodega's People

Contributors

dannynorth avatar jklausa avatar jordanekay avatar mergesort avatar mikakruschel avatar multicolourpixel avatar samalone avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

bodega's Issues

`readDataAndKeys` can provide the wrong data for keys

    public func readDataAndKeys(keys: [CacheKey]) async -> [(key: CacheKey, data: Data)] {
        return zip(
            keys,
            await self.read(keys: keys)
        ).map { ($0, $1) }
    }

self.read(keys: keys) returns an array of data, omitting values to nonexistent keys. However, there's no way for this method to know which keys were omitted.

This means that if an array of keys is passed in with a nonexistent key in the first position, all the subsequent keys will be associated with the wrong Data.

vacuum sqlite database after removing all data

I noticed that even after calling removeAllData, the size of the sqlite3 file doesn't shrink. This is because sqlite reserves the freed space for future use. To compact the database file and reduce disk space, we can use the VACUUM command.

I think it may not be necessary to vacuum the database after every remove command, but it would make sense to call it after removeAllData.

More information about vacuuming can be found at https://blog.eidinger.info/keep-your-coredata-store-small-by-vacuuming#heading-what-is-vacuum.

Let me know if you think it's a good idea, and I'll open a PR.

Don't Include subdirectories in key counts

This isn't very common but if you have a directory such as Database which contains three folders Events, Notes, and Reminders, calling allKeys() on Database will return the sub-databases Events, Notes, and Reminders as keys. While this is technically correct it's a bit unintuitive to how a user would expect this library to work so it would be best to fix that behavior by excluding folders when querying for keys.

Use async StorageEngine

Currently if you want to use await to read or write data in a StorageEngine, for exemple a URLSession or CloudKit based storage for a rest api, you can't do it since none of them are marked async, why not provide async variant or juste use async for everything ?

Create Key protocol and deprecate CacheKey when Swift 5.7 is released

At the moment the implementation of CacheKey is optimized for keys that are file-system safe, accomplished by hashing the CacheKey's String parameter into a String that's safe to save on the file system. As we add StorageEngines (such as SQLiteStorageEngine) that hashing becomes unnecessary, especially since that calculation carries a decent amount of overhead.

It would be possible to accomplish this today by adding a generic constraint to every method in the StorageEngine protocol like so, but that seems to add a decent amount of complexity.

func write<T: Key>(_ data: Data, key: Key) throws

Instead what I propose is to save this change for when Swift 5.7 is released, to have a simple type signature like this.

func write(_ data: Data, key: any Key) throws

The main task is to deprecate CacheKey, create a new Key protocol, and two Key implementers, one focused on the file system, and a generic key that just stores the input String as the Keys value.

We would create a new Key protocol like so.

public protocol Key: Codable, Equatable, Hashable {

    /// The `String` representation of your `Key`.
    var value: String { get }

}

Deprecate the CacheKey type (and conform it to Key for people still using it).

@available(*, deprecated, renamed: "FileSystemSafeStorageKey")
public struct CacheKey: Key {

    public let value: String

    public init(url: URL) {
        let md5HashedURLString = Self.sanitizedURLString(url).md5
        self.init(verbatim: md5HashedURLString.uuidFormatted ?? md5HashedURLString)
    }

    public init(_ value: String) {
        self.init(verbatim: value.md5.uuidFormatted ?? value.md5)
    }

    public init(verbatim value: String) {
        self.value = value
    }

}

Create a new FileSystemSafeStorageKey which is what you will use with DiskStorage instead of CacheKey.

public struct FileSystemSafeStorageKey: Key {

    // โ€ฆ All of the contents of `CacheKey`

}

And a new StorageKey that allows you to use a String as is, with no hashing. This will be what SQLiteStorageEngine uses, and what other StorageEngines will likely default to as well.

public struct StorageKey: Key {

    /// The `String` representation of your `StorageKey`.
    public let value: String

    /// Initializes a `StorageKey` from a `String`, creating a hashed version of the input `String`.
    /// This initializer is useful if you plan on using `StorageKey`s for storing files on disk
    /// because file have many limitations about characters that are allowed in file names,
    /// and the maximum length of a file name.
    /// - Parameter value: The `String` which will serve as the underlying value for this `CacheKey`.
    public init(_ value: String) {
        self.value = value
    }

    public init(url: URL) {
        self.init(url.absoluteString)
    }

}

extension StorageKey: ExpressibleByStringLiteral {

    public init(stringLiteral value: StringLiteralType) {
        self.init(value)
    }

}

Fix Sendable warnings when Swift 6 mode is enabled

Warnings begin to appear about CacheKey and FileManager.Directory being shared across actor boundaries even though they're not marked as Sendable when you enable these flags.

swiftSettings: .unsafeFlags(["-Xfrontend", "-warn-concurrency", "-Xfrontend", "-enable-actor-data-race-checks"])

These warnings aren't time-critical to fix but will need to be addressed in the future.

Dependency on SQLite is incompatible with AWS Amplify

Bodega has an explicit version number dependency:

.package(url: "https://github.com/stephencelis/SQLite.swift.git", exact: Version(0, 13, 3)),

and Amplify has an explicit dependency:

.package(url: "https://github.com/stephencelis/SQLite.swift.git", exact: "0.13.2"),

which makes them incompatible, and now I need to have both in my project.

Any way to change this on Bodega's side? On the side of AWS this is a known issue: aws-amplify/amplify-swift#2592

Integer/Blob CacheKey Support?

I'm curious why there isn't an option for int cache key support? From what I've inspected your using sqlite as a basic key value store. We've found that string keys of uuids use a lot of storage space relative to their blob equivalents. And if you want to use uuids as the basis of your key system, you could store them directly as 128bit blob objects in the sql database and save a lot of space.

We did a test for our own db recently and it reduced our DB 4x in size by replacing our string UUID keys to int / blob equivalents.

It would be really powerful if we would have an Int option for cacheKeys and/or an optimized version for UUIDs. FileStore / JSONStore could use the string representation, but sqllite and maybe others would use the int / uuid representations directly. This would then bubble up to Boutique

https://stackoverflow.com/questions/11337324/how-to-efficient-insert-and-fetch-uuid-in-core-data/11337522#11337522

Support Pagination

Hi @mergesort I've implemented a CloudKit backend for Bodega here (https://github.com/binaryscraping/cloudkit-bodega).

But as it's remote storage, there are some non-ideal parts, such as the fetch of all data and keys, as depending on the amount of data, can be a huge problem.

For CloudKit, I've implemented this by looping through the cursors until all items are fetches, but ideally would be better if Bodega provided some way of paginating the results, then the caller could request the next page whenever it needs, and we don't need to fetch everything upfront.

 private func fetchAllRecords() async throws -> [(CKRecord.ID, Result<CKRecord, Error>)] {
    let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true))
    var results: [(CKRecord.ID, Result<CKRecord, Error>)] = []
    var (matchResults, queryCursor) = try await privateDatabase.records(matching: query)
    results.append(contentsOf: matchResults)

    while let cursor = queryCursor {
      (matchResults, queryCursor) = try await privateDatabase.records(continuingMatchFrom: cursor)
      results.append(contentsOf: matchResults)
    }

    return results
  }

`func read(keys: [CacheKey]) async -> [Data]` should be deprecated

There's no good way to implement this correctly, because there's no way for users to know whether specific passed-in keys have been skipped due to non-existence.

For example:

let keys = [goodKey1, badKey2, badKey3, goodKey4, badKey5, goodKey6]
let data = await storage.read(keys: keys)

// data contains three values, but for which keys?

`SQLiteStorageEngine()` fails to initialize due to `SQLite.Connection.check()` throwing due to invalid path

When moving some dependencies from Cocoapod to Swift package, the Bodega SQLiteStorageEngine started failing to initialize. Initially I thought it was due to some SQLite incompatibility with the dependency and Bodega, but I was able to reproduce by removing the dependency entirely.

One thing stuck out though: the SQLite.Connection error indicated that the database at the path was missing, but the app container had not been removed, and the database had previously worked as expected.

    public init?(directory: FileManager.Directory, databaseFilename filename: String = "data") {
        self.directory = directory

        do {
            if !Self.directoryExists(atURL: directory.url) {
                try Self.createDirectory(url: directory.url)
            }

            self.connection = try Connection(directory.url.appendingPathComponent(filename).appendingPathExtension("sqlite3").absoluteString)
            self.connection.busyTimeout = 3

            try self.connection.run(Self.storageTable.create(ifNotExists: true) { table in
                table.column(Self.expressions.keyRow, primaryKey: true)
                table.column(Self.expressions.dataRow)
                table.column(Self.expressions.createdAtRow, defaultValue: Date())
                table.column(Self.expressions.updatedAtRow, defaultValue: Date())
            })
        } catch {
            return nil
        }
    }

directory.url would pass the fileExists() check, however when I would use FileManager to test the appended database filename URL, it would fail.

(lldb) po FileManager.default.fileExists(atPath: directory.url.appending(path: filename).appendingPathExtension("sqlite3").absoluteString)
false
(lldb) po FileManager.default.fileExists(atPath: directory.url.appending(path: filename).appendingPathExtension("sqlite3").path)
true

Note that the absoluteString does not exist, but path does exist. When the code is changed to use URL.path the database file is found, SQLite.Connection inits, and the SQLStorageEngine inits.

self.connection = try Connection(directory.url.appendingPathComponent(filename).appendingPathExtension("sqlite3").path)

My recommendation is that use of URL.absoluteString is removed and replaced with URL.path and URL.path() for iOS 16 (and other OS versions).

Remove dependency on SQLite.swift

SQLiteSwift is doing rather minimal work in the SQLiteStorageEngine, and was added because I was building a prototype to see the performance of an SQLite-backed StorageEngine. Now that the concept has been proven out quite successfully I'd like to remove the dependency, though I don't have the time to do so at the moment so Bodega can be a free-standing solution without depending on any other libraries.

I'd be very happy to accept help if someone has the expertise to convert the handful of queries that make up the read/write/delete operations in SQLiteStorageEngine, and willing to offer help if you run into any issues. ๐Ÿ™‡๐Ÿปโ€โ™‚๏ธ

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.