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

This commit is contained in:
Matt Bruce 2026-01-14 17:44:13 -06:00
parent c4147f5f38
commit 52034946af
3 changed files with 84 additions and 1 deletions

View File

@ -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 ## 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. 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.

View File

@ -1,11 +1,20 @@
public struct AnyStorageKey: Sendable { public struct AnyStorageKey: Sendable {
public let descriptor: StorageKeyDescriptor public let descriptor: StorageKeyDescriptor
private let migrateAction: @Sendable (StorageRouter) async throws -> Void
public init<Key: StorageKey>(_ key: Key) { public init<Key: StorageKey>(_ key: Key) {
self.descriptor = .from(key) self.descriptor = .from(key)
self.migrateAction = { router in
try await router.migrate(for: key)
}
} }
public static func key<Key: StorageKey>(_ key: Key) -> AnyStorageKey { public static func key<Key: StorageKey>(_ key: Key) -> AnyStorageKey {
AnyStorageKey(key) AnyStorageKey(key)
} }
/// Internal use: Triggers the migration logic for this key.
internal func migrate(on router: StorageRouter) async throws {
try await migrateAction(router)
}
} }

View File

@ -11,6 +11,7 @@ public actor StorageRouter: StorageProviding {
public static let shared = StorageRouter() public static let shared = StorageRouter()
private var registeredKeyNames: Set<String> = [] private var registeredKeyNames: Set<String> = []
private var registeredEntries: [AnyStorageKey] = []
private var storageConfiguration: StorageConfiguration = .default private var storageConfiguration: StorageConfiguration = .default
private init() {} private init() {}
@ -52,11 +53,29 @@ public actor StorageRouter: StorageProviding {
/// Registers a catalog of known storage keys for audit and validation. /// Registers a catalog of known storage keys for audit and validation.
/// When registered, all storage operations will verify keys are listed. /// When registered, all storage operations will verify keys are listed.
public func registerCatalog<C: StorageKeyCatalog>(_ 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<C: StorageKeyCatalog>(_ catalog: C.Type, migrateImmediately: Bool = false) async throws {
let entries = catalog.allKeys let entries = catalog.allKeys
try validateDescription(entries) try validateDescription(entries)
try validateUniqueKeys(entries) try validateUniqueKeys(entries)
registeredKeyNames = Set(entries.map { $0.descriptor.name }) 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 // MARK: - StorageProviding Implementation
@ -126,6 +145,31 @@ public actor StorageRouter: StorageProviding {
throw StorageError.notFound 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<Key: StorageKey>(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. /// Removes the value for the given key.
/// - Parameter key: The storage key to remove. /// - Parameter key: The storage key to remove.
/// - Throws: Domain-specific errors if removal fails. /// - Throws: Domain-specific errors if removal fails.