Boutique Store
Use this skill when you need to persist arrays of items using Boutique's Store, build @Observable data controllers with @Stored, chain store operations, or monitor granular store events.
Prerequisites
- Boutique added as a dependency via Swift Package Manager.
- Models conform to
Codable,Sendable, andIdentifiable(recommended). - iOS 17+ / macOS 14+ deployment target.
- Swift 6.2+ (Boutique uses
@MainActordefault isolation).
Item Requirements
All items stored in a Store must conform to StorableItem, which is a typealias for Codable & Sendable.
struct Note: Codable, Sendable, Identifiable {
let id: String
let text: String
let createdAt: Date
}
Creating a Store
Shortest form (Identifiable with String ID)
When your item conforms to Identifiable with ID == String, the cacheIdentifier is inferred automatically.
let store = Store<Note>(
storage: SQLiteStorageEngine.default(appendingPath: "Notes")
)
Identifiable with UUID ID
When ID == UUID, the store automatically converts to a string identifier.
struct Photo: Codable, Sendable, Identifiable {
let id: UUID
let url: URL
}
let store = Store<Photo>(
storage: SQLiteStorageEngine.default(appendingPath: "Photos")
)
Custom cache identifier
For items that are not Identifiable or need a custom key, provide a KeyPath<Item, String>.
struct Bookmark: Codable, Sendable {
let url: URL
let title: String
}
let store = Store<Bookmark>(
storage: SQLiteStorageEngine.default(appendingPath: "Bookmarks"),
cacheIdentifier: \.url.absoluteString
)
Custom storage directory
let store = Store<Note>(
storage: SQLiteStorageEngine(directory: .documents(appendingPath: "Notes"))!
)
Async initialization (items loaded before returning)
let store = try await Store<Note>(
storage: SQLiteStorageEngine.default(appendingPath: "Notes")
)
// store.items is already populated here
Waiting for items to load after sync init
let store = Store<Note>(
storage: SQLiteStorageEngine.default(appendingPath: "Notes")
)
// Later, when you need items to be ready:
try await store.itemsHaveLoaded()
let notes = store.items
CRUD Operations
Insert
// Single item
try await store.insert(note)
// Multiple items (preferred over calling insert in a loop)
try await store.insert([note1, note2, note3])
Inserting an item with the same cacheIdentifier as an existing item replaces it. The Store handles uniqueness automatically.
Remove
// Single item
try await store.remove(note)
// Multiple items
try await store.remove([note1, note2])
// All items
try await store.removeAll()
Read
let allNotes = store.items // [Note]
Operation Chaining
Chain multiple operations into a single batch to avoid multiple @MainActor dispatches. This prevents flickering in SwiftUI.
// Clear stale cache and insert fresh data
try await store
.removeAll()
.insert(freshNotes)
.run()
// Remove specific items and insert new ones
try await store
.remove(outdatedNote)
.insert(updatedNote)
.run()
You must call .run() at the end of a chain. Without it, the operations are created but never executed.
Building @Observable Controllers with @Stored
The @Stored property wrapper connects a Store to an @Observable class, exposing items as a plain [Item] array and projecting the underlying Store via $.
Standard pattern
@Observable
final class NotesController {
@ObservationIgnored
@Stored var notes: [Note]
init(store: Store<Note>) {
self._notes = Stored(in: store)
}
func fetchNotes() async throws {
let notes = try await self.fetchNotesFromServer()
try await self.$notes.insert(notes)
}
func addNote(_ note: Note) async throws {
try await self.createNoteOnServer(note)
try await self.$notes.insert(note)
}
func removeNote(_ note: Note) async throws {
try await self.deleteNoteOnServer(note)
try await self.$notes.remove(note)
}
func clearAllNotes() async throws {
try await self.deleteAllNotesOnServer()
try await self.$notes.removeAll()
}
}
Key points
self.notesgives you the[Note]array (thewrappedValue).self.$notesgives you theStore<Note>(theprojectedValue) for callinginsert,remove,removeAll.- Always mark
@Storedwith@ObservationIgnoredinside@Observableclasses to prevent duplicate observation tracking. - Inject the
Storeviainitfor testability.
Creating the store and controller
extension Store where Item == Note {
static let notesStore = Store<Note>(
storage: SQLiteStorageEngine.default(appendingPath: "Notes")
)
}
// At your app's entry point or in a DI container
let notesController = NotesController(store: .notesStore)
Granular Event Monitoring
The events property provides an AsyncStream<StoreEvent<Item>> for observing specific operations.
func monitorNotesEvents() async {
for await event in notesController.$notes.events {
switch event.operation {
case .initialized:
print("Store initialized")
case .loaded:
print("Loaded \(event.items.count) notes from disk")
case .insert:
print("Inserted notes:", event.items)
case .remove:
print("Removed notes:", event.items)
}
}
}
StoreEvent operations
| Operation | When it fires | event.items contains |
|---|---|---|
.initialized |
Store created, before loading | Empty array |
.loaded |
Items loaded from storage engine | All loaded items |
.insert |
After insert completes |
The newly inserted items |
.remove |
After remove/removeAll completes |
The removed items |
Common Patterns
Refresh cache from API
func refreshNotes() async throws {
let freshNotes = try await self.api.fetchAllNotes()
try await self.$notes
.removeAll()
.insert(freshNotes)
.run()
}
Static store definitions
extension Store where Item == Note {
static let notesStore = Store<Note>(
storage: SQLiteStorageEngine.default(appendingPath: "Notes")
)
}
extension Store where Item == Photo {
static let photosStore = Store<Photo>(
storage: SQLiteStorageEngine.default(appendingPath: "Photos")
)
}
Notes
- All
Storeoperations are@MainActorisolated andasync throws. - Items are persisted to SQLite automatically on every insert/remove.
- The Store uses an
OrderedDictionaryinternally so item order is preserved. - Prefer
insert([items])over loopinginsert(item)to batch@MainActordispatches. - See
boutique-swiftuiskill for integrating stores with SwiftUI views. - See
boutique-best-practicesskill for testing patterns withStore.previewStore.