From c4147f5f389a7c199f460e2311e6b152cce86da7 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 14 Jan 2026 17:28:42 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- README.md | 31 +++++- Sources/LocalData/Protocols/StorageKey.swift | 7 ++ .../LocalData/Services/StorageRouter.swift | 98 ++++++++++++------- Sources/LocalData/Utilities/Logger.swift | 8 ++ 4 files changed, 108 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 8e6caef..05cc7c6 100644 --- a/README.md +++ b/README.md @@ -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 | |--------|----------| diff --git a/Sources/LocalData/Protocols/StorageKey.swift b/Sources/LocalData/Protocols/StorageKey.swift index 506f16c..8ec430b 100644 --- a/Sources/LocalData/Protocols/StorageKey.swift +++ b/Sources/LocalData/Protocols/StorageKey.swift @@ -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] { [] } } diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift index bf55389..c9e2f39 100644 --- a/Sources/LocalData/Services/StorageRouter.swift +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -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: 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 ) } diff --git a/Sources/LocalData/Utilities/Logger.swift b/Sources/LocalData/Utilities/Logger.swift index a235a00..f6df9e4 100644 --- a/Sources/LocalData/Utilities/Logger.swift +++ b/Sources/LocalData/Utilities/Logger.swift @@ -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)"