Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-14 17:28:42 -06:00
parent ab95353119
commit c4147f5f38
4 changed files with 108 additions and 36 deletions

View File

@ -131,7 +131,36 @@ let token = try await StorageRouter.shared.get(key)
try await StorageRouter.shared.remove(key) try await StorageRouter.shared.remove(key)
``` ```
## Storage Domains ### Complex Codable Support
LocalData handles complex `Codable` types automatically. You are not limited to simple strings or integers.
```swift
struct UserProfile: Codable {
let id: UUID
let name: String
let settings: [String: String]
}
extension StorageKeys {
struct ProfileKey: StorageKey {
typealias Value = UserProfile // Library handles serialization
let name = "user_profile"
let domain: StorageDomain = .fileSystem(directory: .documents)
// ... other properties
}
}
## Storage Design Philosophy
This app intentionally uses a **Type-Safe Storage Design**. Unlike standard iOS development which uses string keys (e.g., `UserDefaults.standard.string(forKey: "user_name")`), this library requires you to define a `StorageKey` type.
### Why types instead of strings?
1. **Safety**: The compiler prevents typos. You can't accidentally load from `"user_name"` and save to `"username"`.
2. **Codable Support**: Keys define their own value types. You can store complex `Codable` structs or classes just as easily as strings, and the library handles the JSON/Plist serialization automatically.
3. **Visibility**: All data your app stores is discoverable in the `StorageKeys/` folder. It serves as a manifest of your app's persistence layer.
4. **Migration**: You can move a piece of data from `UserDefaults` to `EncryptedFileSystem` just by changing the `domain` in the Key definition. No UI code needs to change.
## Storage Key Examples
| Domain | Use Case | | Domain | Use Case |
|--------|----------| |--------|----------|

View File

@ -11,4 +11,11 @@ public protocol StorageKey: Sendable, CustomStringConvertible {
var availability: PlatformAvailability { get } var availability: PlatformAvailability { get }
var syncPolicy: SyncPolicy { get } var syncPolicy: SyncPolicy { get }
/// Optional list of legacy keys to migrate data from if this key is empty.
var migrationSources: [AnyStorageKey] { get }
}
extension StorageKey {
public var migrationSources: [AnyStorageKey] { [] }
} }

View File

@ -72,7 +72,7 @@ public actor StorageRouter: StorageProviding {
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
let data = try serialize(value, with: key.serializer) let data = try serialize(value, with: key.serializer)
let securedData = try await applySecurity(data, for: key, isEncrypt: true) let securedData = try await applySecurity(data, for: .from(key), isEncrypt: true)
try await store(securedData, for: key) try await store(securedData, for: key)
try await handleSync(key, data: securedData) try await handleSync(key, data: securedData)
@ -88,15 +88,42 @@ public actor StorageRouter: StorageProviding {
try validateCatalogRegistration(for: key) try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
guard let securedData = try await retrieve(for: key) else { // 1. Try primary location
Logger.debug("<<< [STORAGE] GET NOT FOUND: \(key.name)") if let securedData = try await retrieve(for: .from(key)) {
throw StorageError.notFound let data = try await applySecurity(securedData, for: .from(key), isEncrypt: false)
let result = try deserialize(data, with: key.serializer)
Logger.debug("<<< [STORAGE] GET SUCCESS: \(key.name)")
return result
} }
let data = try await applySecurity(securedData, for: key, isEncrypt: false) // 2. Try migration sources
let result = try deserialize(data, with: key.serializer) for source in key.migrationSources {
Logger.debug("<<< [STORAGE] GET SUCCESS: \(key.name)") let descriptor = source.descriptor
return result Logger.debug("!!! [STORAGE] MIGRATION: Checking source '\(descriptor.name)' for key '\(key.name)'")
if let securedOldData = try await retrieve(for: descriptor) {
Logger.info("!!! [STORAGE] MIGRATION: Found data for '\(key.name)' at legacy source '\(descriptor.name)'. Migrating...")
// Unsecure using OLD key's policy
let oldData = try await applySecurity(securedOldData, for: descriptor, isEncrypt: false)
// Decode using NEW key's serializer (assuming types are compatible)
let value = try deserialize(oldData, with: key.serializer)
// Store in NEW location with NEW security
// This will also handle sync and catalog validation via recursive call to public set
try await self.set(value, for: key)
// Delete OLD data
try await delete(for: descriptor)
Logger.info("!!! [STORAGE] MIGRATION: Successfully migrated '\(key.name)' from '\(descriptor.name)'")
return value
}
}
Logger.debug("<<< [STORAGE] GET NOT FOUND: \(key.name)")
throw StorageError.notFound
} }
/// Removes the value for the given key. /// Removes the value for the given key.
@ -105,7 +132,7 @@ public actor StorageRouter: StorageProviding {
public func remove<Key: StorageKey>(_ key: Key) async throws { public func remove<Key: StorageKey>(_ key: Key) async throws {
try validateCatalogRegistration(for: key) try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
try await delete(for: key) try await delete(for: .from(key))
} }
/// Checks if a value exists for the given key. /// Checks if a value exists for the given key.
@ -216,10 +243,10 @@ public actor StorageRouter: StorageProviding {
private func applySecurity( private func applySecurity(
_ data: Data, _ data: Data,
for key: any StorageKey, for descriptor: StorageKeyDescriptor,
isEncrypt: Bool isEncrypt: Bool
) async throws -> Data { ) async throws -> Data {
switch key.security { switch descriptor.security {
case .none: case .none:
return data return data
@ -227,13 +254,13 @@ public actor StorageRouter: StorageProviding {
if isEncrypt { if isEncrypt {
return try await EncryptionHelper.shared.encrypt( return try await EncryptionHelper.shared.encrypt(
data, data,
keyName: key.name, keyName: descriptor.name,
policy: encryptionPolicy policy: encryptionPolicy
) )
} else { } else {
return try await EncryptionHelper.shared.decrypt( return try await EncryptionHelper.shared.decrypt(
data, data,
keyName: key.name, keyName: descriptor.name,
policy: encryptionPolicy policy: encryptionPolicy
) )
} }
@ -247,23 +274,24 @@ public actor StorageRouter: StorageProviding {
// MARK: - Storage Operations // MARK: - Storage Operations
private func store(_ data: Data, for key: any StorageKey) async throws { private func store(_ data: Data, for key: any StorageKey) async throws {
switch key.domain { let descriptor = StorageKeyDescriptor.from(key)
switch descriptor.domain {
case .userDefaults(let suite): case .userDefaults(let suite):
try await UserDefaultsHelper.shared.set(data, forKey: key.name, suite: suite) try await UserDefaultsHelper.shared.set(data, forKey: descriptor.name, suite: suite)
case .appGroupUserDefaults(let identifier): case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier) let resolvedId = try resolveIdentifier(identifier)
try await UserDefaultsHelper.shared.set(data, forKey: key.name, appGroupIdentifier: resolvedId) try await UserDefaultsHelper.shared.set(data, forKey: descriptor.name, appGroupIdentifier: resolvedId)
case .keychain(let service): case .keychain(let service):
guard case let .keychain(accessibility, accessControl) = key.security else { guard case let .keychain(accessibility, accessControl) = descriptor.security else {
throw StorageError.securityApplicationFailed throw StorageError.securityApplicationFailed
} }
let resolvedService = try resolveService(service) let resolvedService = try resolveService(service)
try await KeychainHelper.shared.set( try await KeychainHelper.shared.set(
data, data,
service: resolvedService, service: resolvedService,
key: key.name, key: descriptor.name,
accessibility: accessibility, accessibility: accessibility,
accessControl: accessControl accessControl: accessControl
) )
@ -272,7 +300,7 @@ public actor StorageRouter: StorageProviding {
try await FileStorageHelper.shared.write( try await FileStorageHelper.shared.write(
data, data,
to: directory, to: directory,
fileName: key.name, fileName: descriptor.name,
useCompleteFileProtection: false useCompleteFileProtection: false
) )
@ -280,7 +308,7 @@ public actor StorageRouter: StorageProviding {
try await FileStorageHelper.shared.write( try await FileStorageHelper.shared.write(
data, data,
to: directory, to: directory,
fileName: key.name, fileName: descriptor.name,
useCompleteFileProtection: true useCompleteFileProtection: true
) )
@ -289,56 +317,56 @@ public actor StorageRouter: StorageProviding {
try await FileStorageHelper.shared.write( try await FileStorageHelper.shared.write(
data, data,
to: directory, to: directory,
fileName: key.name, fileName: descriptor.name,
appGroupIdentifier: resolvedId, appGroupIdentifier: resolvedId,
useCompleteFileProtection: false useCompleteFileProtection: false
) )
} }
} }
private func retrieve(for key: any StorageKey) async throws -> Data? { private func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
switch key.domain { switch descriptor.domain {
case .userDefaults(let suite): case .userDefaults(let suite):
return try await UserDefaultsHelper.shared.get(forKey: key.name, suite: suite) return try await UserDefaultsHelper.shared.get(forKey: descriptor.name, suite: suite)
case .appGroupUserDefaults(let identifier): case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier) let resolvedId = try resolveIdentifier(identifier)
return try await UserDefaultsHelper.shared.get(forKey: key.name, appGroupIdentifier: resolvedId) return try await UserDefaultsHelper.shared.get(forKey: descriptor.name, appGroupIdentifier: resolvedId)
case .keychain(let service): case .keychain(let service):
let resolvedService = try resolveService(service) let resolvedService = try resolveService(service)
return try await KeychainHelper.shared.get(service: resolvedService, key: key.name) return try await KeychainHelper.shared.get(service: resolvedService, key: descriptor.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory): case .fileSystem(let directory), .encryptedFileSystem(let directory):
return try await FileStorageHelper.shared.read(from: directory, fileName: key.name) return try await FileStorageHelper.shared.read(from: directory, fileName: descriptor.name)
case .appGroupFileSystem(let identifier, let directory): case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier) let resolvedId = try resolveIdentifier(identifier)
return try await FileStorageHelper.shared.read( return try await FileStorageHelper.shared.read(
from: directory, from: directory,
fileName: key.name, fileName: descriptor.name,
appGroupIdentifier: resolvedId appGroupIdentifier: resolvedId
) )
} }
} }
private func delete(for key: any StorageKey) async throws { private func delete(for descriptor: StorageKeyDescriptor) async throws {
switch key.domain { switch descriptor.domain {
case .userDefaults(let suite): case .userDefaults(let suite):
try await UserDefaultsHelper.shared.remove(forKey: key.name, suite: suite) try await UserDefaultsHelper.shared.remove(forKey: descriptor.name, suite: suite)
case .appGroupUserDefaults(let identifier): case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier) let resolvedId = try resolveIdentifier(identifier)
try await UserDefaultsHelper.shared.remove(forKey: key.name, appGroupIdentifier: resolvedId) try await UserDefaultsHelper.shared.remove(forKey: descriptor.name, appGroupIdentifier: resolvedId)
case .keychain(let service): case .keychain(let service):
let resolvedService = try resolveService(service) let resolvedService = try resolveService(service)
try await KeychainHelper.shared.delete(service: resolvedService, key: key.name) try await KeychainHelper.shared.delete(service: resolvedService, key: descriptor.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory): case .fileSystem(let directory), .encryptedFileSystem(let directory):
try await FileStorageHelper.shared.delete(from: directory, fileName: key.name) try await FileStorageHelper.shared.delete(from: directory, fileName: descriptor.name)
case .appGroupFileSystem(let identifier, let directory): case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier) let resolvedId = try resolveIdentifier(identifier)
try await FileStorageHelper.shared.delete( try await FileStorageHelper.shared.delete(
from: directory, from: directory,
fileName: key.name, fileName: descriptor.name,
appGroupIdentifier: resolvedId appGroupIdentifier: resolvedId
) )
} }

View File

@ -12,6 +12,14 @@ enum Logger {
#endif #endif
} }
static func info(_ message: String) {
#if DEBUG
if isLoggingEnabled {
print(" {LOCAL_DATA} 📢 \(message)")
}
#endif
}
static func error(_ message: String, error: Error? = nil) { static func error(_ message: String, error: Error? = nil) {
#if DEBUG #if DEBUG
var logMessage = " {LOCAL_DATA} ❌ \(message)" var logMessage = " {LOCAL_DATA} ❌ \(message)"