top of page

Why use recoverable storage requests in our code?

Code from Cuentiva • Full Xcode project ↗
actor SwiftDataStore {
    private let url: URL
    private var opening: Task<DatabaseWorker, Error>?
    init(url: URL) { self.url = url }
    private func worker() async throws -> DatabaseWorker {
        if let opening { return try await opening.value }
        let url = url
        let task = Task { try await StoreOpener.shared.open(at: url) }
        opening = task
        do { return try await task.value } catch {
            opening = nil
            throw error
        }
    }
    #if DEBUG
        func failNextCommitForTesting() async throws { try await worker().failNextCommitForTesting() }
    #endif
    /// An absent store means no saved records. A fresh read must not create a DB.
    /// Once opening has begun, await that shared operation instead of guessing.
    func readIfPresent(_ collection: String) async throws -> [String: Data]? {
        guard opening != nil || FileManager.default.fileExists(atPath: url.path) else { return nil }
        return try await read(collection)
    }
    func removeCollection(_ collection: String) async throws {
        guard opening != nil || FileManager.default.fileExists(atPath: url.path) else { return }
        try await worker().removeCollection(collection)
    }
    func read(_ collection: String) async throws -> [String: Data]? {
        let started = ContinuousClock.now
        let database = try await worker()
        let opened = ContinuousClock.now
        let rows = try await database.read(collection)
        Logger(subsystem: "com.3DaysOfSwiftConcurrency.Cuentiva", category: "LibraryLoading")
            .info("Storage read \(collection, privacy: .public): opening wait \(String(describing: started.duration(to: opened)), privacy: .public); fetch including actor wait \(String(describing: opened.duration(to: .now)), privacy: .public)")
        return rows
    }
    @discardableResult func replace(_ collection: String, values: [String: Data], onlyIfAbsent: Bool = false)
        async throws -> [String: Data]
    {
        try await worker().replace(collection, values: values, onlyIfAbsent: onlyIfAbsent)
    }
    func apply(_ collection: String, changes: RecordChanges) async throws {
        guard !changes.isEmpty else { return }
        try await worker().apply(collection, changes: changes)
    }
    func put(_ collection: String, key: String, data: Data) async throws {
        try await worker().put(collection, key: key, data: data)
    }
    func value(_ collection: String, key: String) async throws -> Data? {
        try await worker().value(collection, key: key)
    }
}

This is the complete lazy storage owner from Cuentiva, the Spanish-learning app. Its initializer records a URL. Database preparation happens when a request needs the worker, not while AppModel and the first screen are being constructed.

That separation lets the interface exist even when storage is unavailable. A failed data request should produce an honest failure in the affected feature, not replace the entire application with a launch-error screen or an empty in-memory database.

1. The approach

Code from Cuentiva • Full Xcode project ↗
private actor StoreOpener {
    static let shared = StoreOpener()
    func open(at url: URL) throws -> DatabaseWorker {
        try FileManager.default.createDirectory(
            at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        let started = ContinuousClock.now
        let schema = Schema(versionedSchema: CuentivaStoreSchema.self)
        let configuration = ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none)
        let schemaFinished = ContinuousClock.now
        let container = try ModelContainer(
            for: schema, migrationPlan: CuentivaStoreMigrationPlan.self, configurations: [configuration])
        let containerFinished = ContinuousClock.now
        let worker = DatabaseWorker(modelContainer: container)
        Logger(subsystem: "com.3DaysOfSwiftConcurrency.Cuentiva", category: "LibraryLoading")
            .info("SwiftData opening: schema/configuration \(String(describing: started.duration(to: schemaFinished)), privacy: .public); container \(String(describing: schemaFinished.duration(to: containerFinished)), privacy: .public); worker \(String(describing: containerFinished.duration(to: .now)), privacy: .public)")
        return worker
    }
}

This private actor performs the opening work. It first creates the parent directory of the configured store URL, then constructs the versioned schema, configuration, container and database worker. Each step can fail through throws. It does not silently choose another path or remove an obstructing file.

The shared opener serializes its synchronous opening method across independent store owners. Normal database work then belongs to each worker. This is Cuentiva’s implementation choice, not a claim that SwiftData universally forbids multiple containers.

CFA’s documented rationale: production features share an app-owned store, opened lazily behind persistence requests. Views do not choose database locations or create containers. An opening error follows the same request path as a read error, allowing the owning feature to explain what is unavailable.

2. The problem it solves

Code from Trend • Full Xcode project ↗
func load(prepareLoadedData: @MainActor () async -> Void = {}) async {
    await waitForPreviousOperation()
    defer { finishOperation() }
    if state != .ready { state = .loading }
    do {
        publish(try await repository.load())
        await prepareLoadedData()
        state = .ready
    } catch {
        state = .failed(error.localizedDescription)
    }
}

Trend’s weight log illustrates the caller side. This complete load method awaits its repository, prepares derived data, and only then reports readiness. A thrown load becomes a failed state. It is not converted into a successful empty collection.

“There are no measurements” and “the measurements could not be read” are different facts. Treating both as an empty array can hide a problem and encourage a later write to overwrite saved data. Resetting the store or substituting an in-memory container is worse: the app can appear healthy while the person’s records are no longer being used.

This older Trend implementation retains the entries separately from its operation state. Presentation should use that distinction where useful: show confirmed content with a refresh error rather than erasing it just because the latest request failed. The exact cached-content presentation still needs an intentional product decision.

3. Why it works

Code from Trend • Full Xcode project ↗
import Foundation
import Observation

@MainActor
@Observable
final class HistoryViewModel {
    private let history: WeightEntryManager
    var errorMessage: String?

    init(history: WeightEntryManager = AppModel.shared.weightEntries) {
        self.history = history
    }

    var state: WeightLogState { history.weightLogState }
    var entries: [WeightEntry] { history.entries }
    var unit: WeightUnit { history.selectedWeightUnit }

    func delete(_ entry: WeightEntry) async {
        do { try await history.delete(entry) }
        catch { errorMessage = error.localizedDescription }
    }

    func refresh() async { await history.refresh() }
}

Trend’s HistoryViewModel connects to the weight-entry feature and exposes its state and entries. Refresh is an ordinary feature request. A failed deletion becomes a screen message. The ViewModel does not try to repair a database or decide that failure means an empty history.

Code from Cuentiva • Full Xcode project ↗
private func worker() async throws -> DatabaseWorker {
    if let opening { return try await opening.value }
    let url = url
    let task = Task { try await StoreOpener.shared.open(at: url) }
    opening = task
    do { return try await task.value } catch {
        opening = nil
        throw error
    }
}

Back in Cuentiva, callers of worker() join the stored opening task. The successful task continues to provide the same worker. On failure, this implementation clears the handle and rethrows so a later request can attempt opening again.

There is an important limit in this older code: several callers can be awaiting a failed opening. A late failure handler could clear a newer retry’s handle. A production hardening pass should tie cleanup to the opening attempt it belongs to and test that overlap. Lazy opening demonstrates the boundary; it is not proof that every retry race has already been solved.

Swift’s actor reentrancy rules are why we review across each await rather than assuming actor isolation makes an entire asynchronous request atomic.

4. Advantages of using it

Code from Trend • Full Xcode project ↗
struct RootView: View {
    @State private var viewModel = RootViewModel()
    @Environment(ThemeManager.self) private var themeManager

    var body: some View {
        @Bindable var viewModel = viewModel
        TabView {
            // Tab-bar preferences flow from each tab's content to its enclosing TabView.
            TodayView()
                .tabItem { Label("Today", systemImage: "plus.circle.fill") }
                .toolbarBackground(.ultraThinMaterial, for: .tabBar)
                .toolbarBackground(.visible, for: .tabBar)
            ProjectionView()
                .tabItem { Label("Trend", systemImage: "chart.xyaxis.line") }
                .toolbarBackground(.ultraThinMaterial, for: .tabBar)
                .toolbarBackground(.visible, for: .tabBar)
            HabitsView()
                .tabItem { Label("Habits", systemImage: "scope") }
                .toolbarBackground(.ultraThinMaterial, for: .tabBar)
                .toolbarBackground(.visible, for: .tabBar)
            SettingsView()
                .tabItem { Label("Settings", systemImage: "gearshape") }
                .toolbarBackground(.ultraThinMaterial, for: .tabBar)
                .toolbarBackground(.visible, for: .tabBar)
        }
        .background(themeManager.palette.background.ignoresSafeArea())
        .tint(themeManager.palette.accent)
        .onOpenURL { viewModel.open($0) }
        .sheet(item: $viewModel.importSelection, onDismiss: viewModel.importDismissed) { selection in
            WeightImportView(url: selection.url)
        }
    }
}

Trend’s root constructs navigation without first asking whether the weight database opened. The Today, Trend, Habits and Settings interfaces have their own entry points. A feature failure does not need to remove the navigation hierarchy.

Editorial trade-off: this design requires meaningful unavailable, loading and retry states in affected screens. It does not make a feature usable without its required data, and it does not guarantee independent tabs have no shared dependencies. It keeps the failure’s scope honest.

Verify fresh-directory creation, reopening existing data, failure followed by retry and concurrent first requests. Then exercise the interface while storage is unavailable. A passing source check cannot establish that navigation remains usable on an iPhone; that is a separate integration and UI check.

Try this with the CFA AI Toolkit for iOS:

Using CFA for iOS, keep storage failures local to the affected feature and add an honest retry path.

Scope: code from the linked app revisions; smaller excerpts belong to their containing types and targets. The articles distinguish older implementations from current CFA conventions. Source verification is not a full app build or device test. Suggested checks are guidance, not claims they ran against your application.

bottom of page