FS
ForgeStorage

ForgeKVStore

Type-safe key-value storage for settings, flags, and small serialized data. One protocol, three backing stores. Add reactive observation via ReactiveKVStore.

Import
import ForgeKVStore

KVKey

Every value is accessed through a KVKey<Value> that pairs a string identifier with a type. The Value must conform to Codable & Sendable. There is no default value — get() returns nil when nothing has been stored.

KVKey.swift
public struct KVKey<Value: Codable & Sendable>: Sendable, Equatable {
    public let key: String

    public init(_ key: String)
}

Defining Keys

Group keys by domain using enum namespaces. Use dot-separated prefixes to avoid collisions. Supply the value type as the generic parameter.

Keys.swift
enum TaskKeys {
    static let onboarding = KVKey<Bool>("taskflow.onboarding")
    static let projectName = KVKey<String?>("taskflow.projectName")
    static let taskCount = KVKey<Int>("taskflow.taskCount")
    static let settings = KVKey<TaskSettings>("taskflow.settings")
}

enum CacheKeys {
    static let lastPurgeDate = KVKey<Date?>("cache.lastPurgeDate")
}

Supported Types

Any Codable type works: Bool, Int, Double, Float, String, Date, Data, URL, optionals, arrays, dictionaries with Codable elements, and custom Codable structs or enums. The store automatically selects the optimal encoding path — native for primitives, JSON for custom types.

KVStoring Protocol

All stores conform to KVStoring. Depend on the protocol, not the concrete type. Note: get() returns an optional, and set(), remove(), removeAll() all throw.

KVStoring.swift
public protocol KVStoring: Sendable {
    func get<T: Codable & Sendable>(_ key: KVKey<T>) -> T?
    func set<T: Codable & Sendable>(_ key: KVKey<T>, value: T) throws
    func remove<T: Codable & Sendable>(_ key: KVKey<T>) throws
    func contains<T: Codable & Sendable>(_ key: KVKey<T>) -> Bool
    func removeAll() throws
}

The protocol does not include stream(). For reactive observation, wrap any store in ReactiveKVStore.

API Reference

get(_:)

Returns the stored value, or nil if nothing has been set. Use the nil-coalescing operator (??) to provide a fallback.

get.swift
let count = store.get(TaskKeys.taskCount) ?? 0    // Int
let name = store.get(TaskKeys.projectName)         // String? — nil if not set

set(_:value:)

Stores a value. Overwrites any existing value for the key. Throws on encoding or write failure.

set.swift
try store.set(TaskKeys.taskCount, value: 42)
try store.set(TaskKeys.projectName, value: "TaskFlow v2")

remove(_:)

Removes the stored value. Subsequent reads return nil.

remove.swift
try store.remove(TaskKeys.projectName)
store.get(TaskKeys.projectName) // nil

contains(_:)

Returns true if a value was explicitly set for the key.

contains.swift
store.contains(TaskKeys.taskCount) // false (not yet set)
try store.set(TaskKeys.taskCount, value: 1)
store.contains(TaskKeys.taskCount) // true

removeAll()

Removes all values from the store.

removeAll.swift
try store.removeAll()

UserDefaultsKVStore

Production store backed by UserDefaults. Conforms to KVStoring. Supports suite-based sharing for app groups (widgets, extensions).

UserDefaultsKVStore.swift
// Standard UserDefaults
let store = UserDefaultsKVStore()

// App group suite (for widgets, extensions)
let sharedStore = UserDefaultsKVStore(suiteName: "group.com.taskflow.shared")

// Pre-existing UserDefaults instance
let custom = UserDefaultsKVStore(defaults: myDefaults)

Encoding is automatic: native types (Bool, Int, Double, Float, String, Date, Data) are stored natively in UserDefaults. Custom Codable types are JSON-encoded.

DiskKVStore

File-based key-value store. A stateless struct — each method performs independent file I/O. Each key maps to a JSON file: {directory}/{namespace}/{sanitized-key}.json. Use this for values too large for UserDefaults or when you need specific file protection.

DiskKVStore.swift
let store = DiskKVStore(
    directory: .applicationSupport,
    namespace: "taskflow-settings",
    fileProtection: .complete  // Encrypted when device locked
)

When to Use DiskKVStore vs UserDefaultsKVStore

Criteria UserDefaultsKVStore DiskKVStore
Small values (< 1KB) Preferred Overkill
Large values (> 100KB) Performance issues Preferred
File protection control Limited Per-store configurable
Cross-process sharing Via app group suite Not supported

Directory Options

  • .documents — User-visible, backed up by iCloud.
  • .applicationSupport — App-managed, backed up. Best for persistent settings.
  • .caches — Not backed up, may be purged by the system on low disk.

File Protection

Set via the fileProtection parameter at init. Applied to the directory and every file written.

FileProtection.swift
// Default — accessible after first unlock (good for background tasks)
DiskKVStore(directory: .applicationSupport, namespace: "taskflow-data")

// Complete protection — only accessible when device is unlocked
DiskKVStore(
    directory: .applicationSupport,
    namespace: "taskflow-sensitive",
    fileProtection: .complete
)

InMemoryKVStore

Dictionary-backed store for tests and SwiftUI previews. No persistence — values exist only for the lifetime of the instance. Thread-safe via LockedState. Conforms to the same KVStoring protocol.

InMemoryKVStore.swift
// In tests
let store = InMemoryKVStore()
let viewModel = TaskFlowViewModel(store: store)

// Assert default state
#expect(store.get(TaskKeys.taskCount) == nil)

// Mutate and verify
try store.set(TaskKeys.taskCount, value: 5)
#expect(store.get(TaskKeys.taskCount) == 5)

ReactiveKVStore

ReactiveKVStore wraps any KVStoring implementation and adds per-key observation via AsyncStream. This is the only way to get stream() — the KVStoring protocol itself does not include it.

Writes must go through the ReactiveKVStore wrapper for observation to work. The underlying store is accessed directly — no caching layer.

ReactiveKVStore.swift
// Wrap any KVStoring in ReactiveKVStore
let store = UserDefaultsKVStore()
let reactive = ReactiveKVStore(store)

// ReactiveKVStore conforms to KVStoring — use it the same way
try reactive.set(TaskKeys.onboarding, value: true)
let done = reactive.get(TaskKeys.onboarding) // Optional(true)

stream(_:)

Returns an AsyncStream<T?> that emits the current value immediately, then yields on every change. Duplicate values are skipped. The value type must conform to Equatable.

stream.swift
// Observe a key
for await value in reactive.stream(TaskKeys.taskCount) {
    print("Task count: \(value ?? 0)")
}

// In a SwiftUI ViewModel
func observeOnboarding() {
    Task {
        for await isDone in reactive.stream(TaskKeys.onboarding) {
            await MainActor.run {
                self.showOnboarding = !(isDone ?? false)
            }
        }
    }
}

KVStoreError

Errors thrown by KVStoring operations. All mutating methods (set, remove, removeAll) throw on failure.

KVStoreError.swift
public enum KVStoreError: LocalizedError, Sendable {
    case encodingFailed(key: String, underlying: String)
    case writeFailed(key: String, underlying: String)
    case removeFailed(key: String, underlying: String)
    case removeAllFailed(underlying: String)
}
ErrorHandling.swift
do {
    try store.set(TaskKeys.settings, value: settings)
} catch let error as KVStoreError {
    switch error {
    case .encodingFailed(let key, let underlying):
        print("Encoding failed for '\(key)': \(underlying)")
    case .writeFailed(let key, let underlying):
        print("Write failed for '\(key)': \(underlying)")
    default:
        print(error.localizedDescription)
    }
}