Boutique Documentation

repository·main·Indexed 22 days ago

https://github.com/mergesort/boutique

A persistence library for Swift (SwiftUI, UIKit, AppKit) providing a dual-layered memory and disk caching architecture for real-time, offline-ready data management. It features a Store for managing collections of StorableItem data, as well as @Stored, @StoredValue, and @SecurelyStoredValue property wrappers for caching and UserDefaults persistence. Optimized for thousands of small objects, Boutique leverages Bodega as its underlying actor-based data storage engine.

Tokens
13.1K
Snippets
40
Records
50
Agent score
78%

What's inside Boutique

  1. Overview of Boutique's architecture

    main

    Boutique is a persistence library designed for SwiftUI, UIKit, and AppKit applications. It uses a dual-layered memory + disk caching architecture to provide real-time updates and full offline storage.

    Key characteristics include:

    • Automatic Persistence: Data saved to a Store is automatically persisted to disk.
    • Real-time Updates: State changes propagate to all views automatically.
    • Simple API: Data is exposed as regular Swift arrays or values, minimizing the need for complex database logic.
    • Architecture Agnostic: While it promotes a Model-View-Controller-Store pattern, it can be integrated into any existing architecture.
  2. Overview of Boutique's Store capabilities

    main
    Boutique provides a batteries-included Store that acts as a dual-layered memory and disk cache. It allows you to build apps with real-time updates and full offline storage using a simple API. When you save an object into the Store, it is automatically persisted to a database. This persistence layer is powered by Bodega, an actor-based data storage engine.
  3. Core concepts of Boutique: Stores and Property Wrappers

    main

    Boutique's functionality centers around two primary abstractions:

    1. Store: When you save data to a Store, it is automatically persisted. The data is exposed to your application as a regular Swift array.
    2. Property Wrappers:
      • @StoredValue: Used to save and retrieve a singular Swift value. You update it using $storedValue.set(value).
      • @SecurelyStoredValue: Works identically to @StoredValue but is intended for sensitive data requiring secure storage.

    Unlike Redux or The Composable Architecture, Boutique does not require defining Actions or Reducers; persistence is handled automatically.

  4. Use @Stored in @Observable controllers

    main

    The @Stored property wrapper connects a Store to an @Observable class. It exposes the items as a plain array and provides access to the underlying Store via the projected value ($).

    Implementation Rules:

    • Observation: Always mark @Stored with @ObservationIgnored inside @Observable classes to prevent duplicate observation tracking.
    • Accessing Data: Use self.notes to access the [Item] array (the wrappedValue).
    • Accessing Store: Use self.$notes to access the Store<Item> (the projectedValue) to call insert, remove, or removeAll.
    • Dependency Injection: Inject the Store via the init for better testability.
    @Observable
    final class NotesController {
        @ObservationIgnored
        @Stored var notes: [Note]
    
        init(store: Store<Note>) {
            self._notes = Stored(in: store)
        }
    
        func addNote(_ note: Note) async throws {
            try await self.createNoteOnServer(note)
            try await self.$notes.insert(note)
        }
    }
  5. Use @Stored to cache properties in a Store

    main

    The @Stored property wrapper allows you to link a single property to a Store. This creates an in-memory and on-disk cache for that value. It is highly useful for sharing state across multiple views in SwiftUI, UIKit, or AppKit. Because @Stored is @Observable, views can react to changes automatically.

    extension Store where Item == Note {
        static let notesStore = Store<Note>(
            storage: SQLiteStorageEngine.default(appendingPath: "Notes")
        )
    }
    
    @Observable
    final class NotesController {
        /// Automatically handles in-memory and on-disk caching
        @Stored(in: .notesStore) var notes
    
        func saveNote(note: Note) async throws {
            try await self.$notes.insert(note)
        }
    }
  6. Conform models to StorableItem

    main

    All items stored in Boutique must conform to StorableItem, which requires Codable & Sendable.

    • Structs (Preferred): Structs receive Sendable conformance automatically if all their stored properties are Sendable.
    • Enums: Enums can be used as stored items if they conform to Codable, Sendable, and Equatable.
    struct Note: Codable, Sendable, Identifiable, Equatable {
        let id: String
        let text: String
        let createdAt: Date
    }
    
    enum Theme: String, Codable, Sendable, Equatable {
        case light
        case dark
        case system
    }
  7. Best practices for storing data in Boutique

    main

    While Boutique can store binary data like images, it is not recommended for large binary blobs. Storing images in Boutique can cause significant memory ballooning.

    For storing images or other large binary data to disk, use Bodega instead. Boutique is optimized for handling thousands of small objects rather than large binary files.

  8. Understand the @MainActor binding in Boutique

    main

    The Store, StoredValue, and SecurelyStoredValue are annotated with @MainActor.

    What this means:

    • Synchronous access to the Store is safe and intended for the main thread.
    • It does not force heavy asynchronous work (like disk I/O or network requests) onto the main thread.
    • The underlying Bodega framework performs all heavy lifting (persistence/loading) asynchronously on background threads.

    Best Practice: Avoid performing heavy synchronous computations inside a @MainActor scope, as that will block the main thread. Boutique's internal work is designed to be lightweight and offload heavy tasks to background threads automatically.

  9. Explore Boutique property wrappers: @Stored, @StoredValue, and @SecurelyStoredValue

    main

    Boutique provides a family of property wrappers designed to work with regular Swift values and arrays while handling automatic data persistence. This allows you to build offline-first, state-driven SwiftUI applications without managing a database manually.

    Key property wrappers include:

    • @Stored
    • @StoredValue
    • @SecurelyStoredValue
  10. Architectural recommendations for Boutique

    main

    When building applications with Boutique, follow these architectural patterns:

    1. One controller per domain: Create focused @Observable controllers per data domain (e.g., NotesController, PhotosController) rather than one monolithic controller.
    2. Store as implementation detail: Expose domain-specific methods (like addNote or removeNote) on your controllers. Do not expose the Store directly to your SwiftUI views.
    3. API-first, store-second: Perform API calls first, then sync the results to the local store upon success. This ensures the local store acts as a reliable cache of the server's state.
    4. Preferences as separate classes: If you have large preference objects, break them into smaller @Observable classes grouped by specific feature areas.
  11. Handle Swift 6 concurrency and @MainActor isolation

    main

    Boutique's Store, StoredValue, and SecurelyStoredValue are all @MainActor isolated. In Swift 6 with strict concurrency, you must ensure store operations are called from a @MainActor context.

    • All store operations (insert, remove, removeAll) must be called from a @MainActor context.
    • Controllers using @Stored, @StoredValue, or @SecurelyStoredValue are implicitly @MainActor because the property wrappers are @MainActor.
    • SwiftUI views are already @MainActor, so no extra annotation is required there.

    When calling operations from a background task or non-isolated function, the transition to the @MainActor happens automatically when you call the controller's methods.

    // From a background task or non-isolated function
    func syncData() async throws {
        let data = try await self.api.fetchData() // Can run off main actor
        try await self.controller.updateStore(with: data) // MainActor hop happens automatically
    }
  12. Organize large state with nested @Observable objects

    main

    Because Boutique integrates with Swift's @Observable macro, you can break down large, monolithic state objects into smaller, focused, and reactive sub-objects. This allows you to maintain reactivity across a hierarchy of models while keeping individual components maintainable.

    @Observable
    final class Preferences {
        var userExperiencePreferences = UserExperiencePreferences()
        var redPandaPreferences = RedPandaPreferences()
    }
    
    @MainActor
    @Observable
    final class UserExperiencePreferences {
        @ObservationIgnored
        @StoredValue(key: "hasSoundEffectsEnabled")
        public var hasSoundEffectsEnabled = false
    }