From 9859ec155771b36850a1b26e68b4685423837bcd Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 14 Jan 2026 17:44:13 -0600 Subject: [PATCH] Update Models, Services and docs Summary: - Sources: update Models, Services - Docs: update docs for README Stats: - 3 files changed, 84 insertions(+), 1 deletion(-) --- README.md | 30 ++++++++++++ Sources/LocalData/Models/AnyStorageKey.swift | 9 ++++ .../LocalData/Services/StorageRouter.swift | 46 ++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 05cc7c6..2714657 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,36 @@ extension StorageKeys { } } + // ... other properties + } +} + +## Data Migration + +LocalData supports migrating data between different storage keys and domains (e.g., from a legacy `UserDefaults` string key to a modern secure `Keychain` key). + +### 1. Automatic Fallback (Lazy) +When you define `migrationSources` on a key, `StorageRouter.get(key)` will automatically check those sources if the primary key is not found. If data exists in a source: +- It is retrieved using the source's metadata. +- It is saved to the new key using the new security policy. +- It is deleted from the source. +- It is returned to the caller. + +### 2. Proactive Sweep (Drain) +To ensure no "ghost data" remains in legacy keys (e.g., if a bug causes old code to write to them again), you can use either a manual call or an automated startup sweep. + +#### Manual Call +```swift +try await StorageRouter.shared.migrate(for: StorageKeys.ModernKey()) +``` + +#### Automated Startup Sweep +When registering a catalog, you can enable `migrateImmediately` to perform a global sweep of all legacy keys for every key in the catalog. This ensures your storage is clean at every app launch. + +```swift +try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self, migrateImmediately: true) +``` + ## 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. diff --git a/Sources/LocalData/Models/AnyStorageKey.swift b/Sources/LocalData/Models/AnyStorageKey.swift index d31bbfd..aafbb9d 100644 --- a/Sources/LocalData/Models/AnyStorageKey.swift +++ b/Sources/LocalData/Models/AnyStorageKey.swift @@ -1,11 +1,20 @@ public struct AnyStorageKey: Sendable { public let descriptor: StorageKeyDescriptor + private let migrateAction: @Sendable (StorageRouter) async throws -> Void public init(_ key: Key) { self.descriptor = .from(key) + self.migrateAction = { router in + try await router.migrate(for: key) + } } public static func key(_ key: Key) -> AnyStorageKey { AnyStorageKey(key) } + + /// Internal use: Triggers the migration logic for this key. + internal func migrate(on router: StorageRouter) async throws { + try await migrateAction(router) + } } diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift index c9e2f39..71d09f2 100644 --- a/Sources/LocalData/Services/StorageRouter.swift +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -11,6 +11,7 @@ public actor StorageRouter: StorageProviding { public static let shared = StorageRouter() private var registeredKeyNames: Set = [] + private var registeredEntries: [AnyStorageKey] = [] private var storageConfiguration: StorageConfiguration = .default private init() {} @@ -52,11 +53,29 @@ public actor StorageRouter: StorageProviding { /// Registers a catalog of known storage keys for audit and validation. /// When registered, all storage operations will verify keys are listed. - public func registerCatalog(_ catalog: C.Type) throws { + /// - Parameters: + /// - catalog: The catalog type to register. + /// - migrateImmediately: If true, triggers a proactive migration (sweep) for all keys in the catalog. + public func registerCatalog(_ catalog: C.Type, migrateImmediately: Bool = false) async throws { let entries = catalog.allKeys try validateDescription(entries) try validateUniqueKeys(entries) registeredKeyNames = Set(entries.map { $0.descriptor.name }) + registeredEntries = entries + + if migrateImmediately { + try await migrateAllRegisteredKeys() + } + } + + /// Triggers a proactive migration (sweep) for all registered storage keys. + /// This "drains" any legacy data into the modern storage locations. + public func migrateAllRegisteredKeys() async throws { + Logger.debug(">>> [STORAGE] STARTING GLOBAL MIGRATION SWEEP") + for entry in registeredEntries { + try await entry.migrate(on: self) + } + Logger.debug("<<< [STORAGE] GLOBAL MIGRATION SWEEP COMPLETE") } // MARK: - StorageProviding Implementation @@ -126,6 +145,31 @@ public actor StorageRouter: StorageProviding { throw StorageError.notFound } + /// Manually triggers migration from legacy sources for a specific key. + /// This is useful for "draining" old keys at app startup even if the destination already has data. + /// - Parameter key: The storage key to migrate. + /// - Throws: Various storage and serialization errors. + public func migrate(for key: Key) async throws { + Logger.debug(">>> [STORAGE] MANUAL MIGRATION: \(key.name)") + + for source in key.migrationSources { + let descriptor = source.descriptor + if let securedOldData = try await retrieve(for: descriptor) { + Logger.info("!!! [STORAGE] MANUAL MIGRATION: Migrating found data from '\(descriptor.name)' to '\(key.name)'") + + let oldData = try await applySecurity(securedOldData, for: descriptor, isEncrypt: false) + let value = try deserialize(oldData, with: key.serializer) + + // Store in NEW location + try await self.set(value, for: key) + + // Delete OLD data + try await delete(for: descriptor) + } + } + Logger.debug("<<< [STORAGE] MANUAL MIGRATION COMPLETE: \(key.name)") + } + /// Removes the value for the given key. /// - Parameter key: The storage key to remove. /// - Throws: Domain-specific errors if removal fails.