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)
```
## 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 |
|--------|----------|

View File

@ -11,4 +11,11 @@ public protocol StorageKey: Sendable, CustomStringConvertible {
var availability: PlatformAvailability { 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)
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 handleSync(key, data: securedData)
@ -88,15 +88,42 @@ public actor StorageRouter: StorageProviding {
try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key)
guard let securedData = try await retrieve(for: key) else {
Logger.debug("<<< [STORAGE] GET NOT FOUND: \(key.name)")
throw StorageError.notFound
// 1. Try primary location
if let securedData = try await retrieve(for: .from(key)) {
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)
let result = try deserialize(data, with: key.serializer)
Logger.debug("<<< [STORAGE] GET SUCCESS: \(key.name)")
return result
// 2. Try migration sources
for source in key.migrationSources {
let descriptor = source.descriptor
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.
@ -105,7 +132,7 @@ public actor StorageRouter: StorageProviding {
public func remove<Key: StorageKey>(_ key: Key) async throws {
try validateCatalogRegistration(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.
@ -216,10 +243,10 @@ public actor StorageRouter: StorageProviding {
private func applySecurity(
_ data: Data,
for key: any StorageKey,
for descriptor: StorageKeyDescriptor,
isEncrypt: Bool
) async throws -> Data {
switch key.security {
switch descriptor.security {
case .none:
return data
@ -227,13 +254,13 @@ public actor StorageRouter: StorageProviding {
if isEncrypt {
return try await EncryptionHelper.shared.encrypt(
data,
keyName: key.name,
keyName: descriptor.name,
policy: encryptionPolicy
)
} else {
return try await EncryptionHelper.shared.decrypt(
data,
keyName: key.name,
keyName: descriptor.name,
policy: encryptionPolicy
)
}
@ -247,23 +274,24 @@ public actor StorageRouter: StorageProviding {
// MARK: - Storage Operations
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):
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):
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):
guard case let .keychain(accessibility, accessControl) = key.security else {
guard case let .keychain(accessibility, accessControl) = descriptor.security else {
throw StorageError.securityApplicationFailed
}
let resolvedService = try resolveService(service)
try await KeychainHelper.shared.set(
data,
service: resolvedService,
key: key.name,
key: descriptor.name,
accessibility: accessibility,
accessControl: accessControl
)
@ -272,7 +300,7 @@ public actor StorageRouter: StorageProviding {
try await FileStorageHelper.shared.write(
data,
to: directory,
fileName: key.name,
fileName: descriptor.name,
useCompleteFileProtection: false
)
@ -280,7 +308,7 @@ public actor StorageRouter: StorageProviding {
try await FileStorageHelper.shared.write(
data,
to: directory,
fileName: key.name,
fileName: descriptor.name,
useCompleteFileProtection: true
)
@ -289,56 +317,56 @@ public actor StorageRouter: StorageProviding {
try await FileStorageHelper.shared.write(
data,
to: directory,
fileName: key.name,
fileName: descriptor.name,
appGroupIdentifier: resolvedId,
useCompleteFileProtection: false
)
}
}
private func retrieve(for key: any StorageKey) async throws -> Data? {
switch key.domain {
private func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
switch descriptor.domain {
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):
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):
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):
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):
let resolvedId = try resolveIdentifier(identifier)
return try await FileStorageHelper.shared.read(
from: directory,
fileName: key.name,
fileName: descriptor.name,
appGroupIdentifier: resolvedId
)
}
}
private func delete(for key: any StorageKey) async throws {
switch key.domain {
private func delete(for descriptor: StorageKeyDescriptor) async throws {
switch descriptor.domain {
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):
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):
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):
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):
let resolvedId = try resolveIdentifier(identifier)
try await FileStorageHelper.shared.delete(
from: directory,
fileName: key.name,
fileName: descriptor.name,
appGroupIdentifier: resolvedId
)
}

View File

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