Update Migrations, Models, Protocols (+2 more) + tests + docs

Summary:
- Sources: Migrations, Models, Protocols, Services, Utilities
- Tests: AnyStorageKeyTests.swift, MigrationAdditionalTests.swift, MigrationIntegrationTests.swift, MigrationProtocolTests.swift, MigrationTests.swift (+1 more)
- Docs: Migration, Migration_Refactor_Plan_Clean, Proposal, README
- Added symbols: struct MyMigration, typealias DestinationKey, func shouldMigrate, func migrate, extension MyNewKey, protocol StorageMigration (+74 more)
- Removed symbols: enum StorageDomain, func migrate, func validatePlatformAvailability, func deserialize, func applySecurity, func retrieve (+1 more)

Stats:
- 31 files changed, 2820 insertions(+), 80 deletions(-)
This commit is contained in:
Matt Bruce 2026-01-16 13:47:12 -06:00
parent 35ce409f4e
commit c2b17a4b7f
31 changed files with 2820 additions and 80 deletions

View File

@ -1,15 +1,15 @@
# LocalData Migration Guide
## Overview
`LocalData` provides built-in support for migrating data from legacy storage locations or keys to modern `StorageKey` definitions.
`LocalData` provides protocol-based migration support to move data from legacy storage locations to modern `StorageKey` definitions.
## Automatic Migration
When calling `get(_:)` on a key, the `StorageRouter` automatically:
1. Checks the primary location.
2. If not found, iterates through `migrationSources` defined on the key.
2. If not found, evaluates `migration` defined on the key.
3. If data is found in a source:
- Unsecures it using the source's old policy.
- Re-secures it using the new key's policy.
- Re-secures it using the destination key's policy.
- Stores it in the new location.
- Deletes the legacy data.
- Returns the value.
@ -17,19 +17,48 @@ When calling `get(_:)` on a key, the `StorageRouter` automatically:
## Proactive Migration (Sweep)
You can trigger a sweep of all registered keys at app launch:
```swift
try await StorageRouter.shared.registerCatalog(MyCatalog.self, migrateImmediately: true)
try await StorageRouter.shared.registerCatalog(MyCatalog(), migrateImmediately: true)
```
This iterates through all keys in the catalog and calls `migrate(for:)` on each, ensuring all legacy data is consolidated.
This iterates through all keys in the catalog and calls `forceMigration(for:)` on each, ensuring all legacy data is consolidated.
## Defining Migration Sources
When defining a `StorageKey`, add legacy descriptors to the `migrationSources` array:
### Simple Legacy Migration
For 1:1 migrations, attach a `SimpleLegacyMigration`:
```swift
struct MyNewKey: StorageKey {
// ...
var migrationSources: [AnyStorageKey] {
[
.key(LegacyKey(name: "old_key_name", domain: .userDefaults(suite: nil)))
]
var migration: AnyStorageMigration? {
AnyStorageMigration(
SimpleLegacyMigration(
destinationKey: self,
sourceKey: .key(LegacyKey(name: "old_key_name", domain: .userDefaults(suite: nil)))
)
)
}
}
```
### Protocol-Based Migration
For complex scenarios, attach an explicit migration:
```swift
struct MyMigration: StorageMigration {
typealias DestinationKey = MyNewKey
let destinationKey = MyNewKey()
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
try await router.exists(destinationKey)
}
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
// Custom migration logic
MigrationResult(success: true)
}
}
extension MyNewKey {
var migration: AnyStorageMigration? {
AnyStorageMigration(MyMigration())
}
}
```

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,7 @@ Create a single, typed, discoverable namespace for persisted app data with consi
- **StorageRouter** actor - Main entry point coordinating all storage operations
- **StorageProviding** protocol - Abstraction for storage operations
- **StorageKeyCatalog** protocol - Catalog of keys for auditing/validation
- **StorageMigration** protocol - Protocol-based migration workflows attached to keys
### Isolated Helper Classes (Actors)
Each helper is a dedicated actor providing thread-safe access to a specific storage domain:
@ -44,6 +45,7 @@ Each helper is a dedicated actor providing thread-safe access to a specific stor
- **StorageError** - Comprehensive error types
- **StorageKeyDescriptor** - Audit snapshot of a keys storage metadata
- **AnyStorageKey** - Type-erased storage key for catalogs
- **AnyStorageMigration** - Type-erased migration for protocol-based workflows
- **AnyCodable** - Type-erased Codable for mixed-type payloads
## Usage Pattern
@ -65,7 +67,6 @@ StorageRouter can call WCSession.updateApplicationContext for manual or automati
- watchOS 10+
## Future Ideas (Not Implemented)
- Migration helpers for legacy storage
- Key rotation strategies for encrypted data
- Watch-optimized data representations

View File

@ -75,6 +75,10 @@ flowchart TD
- **StorageKey** - Define storage configuration for each data type
- **StorageProviding** - Abstraction for storage operations
- **KeyMaterialProviding** - Supplies external key material for encryption
- **StorageMigration** - Protocol-based migration workflows
- **ConditionalMigration** - Marker protocol for conditional migrations
- **TransformingMigration** - Migrations that transform source values
- **AggregatingMigration** - Migrations that aggregate multiple sources
### Services (Actors)
- **StorageRouter** - Main entry point for all storage operations
@ -109,6 +113,15 @@ These are used at app lifecycle start to tune library engine behaviors:
- **StorageKeyDescriptor** - Audit snapshot of a keys storage metadata
- **AnyStorageKey** - Type-erased storage key for catalogs
- **AnyCodable** - Type-erased Codable for mixed-type payloads
- **AnyStorageMigration** - Type-erased migration for catalogs and registrations
- **MigrationContext** - Context for conditional migrations
- **MigrationResult** - Migration outcome and error reporting
- **MigrationError** - Migration error cases
### Utilities
- **DeviceInfo** - Device metadata used in migration context
- **SystemInfo** - System metrics used in migration context
- **MigrationUtils** - Common migration helpers
## Usage
@ -176,21 +189,67 @@ extension StorageKeys {
## 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).
LocalData supports protocol-based migrations between 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:
### 1. Automatic Migration (Lazy)
When you define a `migration` on a key, `StorageRouter.get(key)` will automatically run it if the primary key is not found. If data exists:
- 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)
```swift
extension StorageKeys {
struct ModernKey: StorageKey {
typealias Value = String
// ... other properties
var migration: AnyStorageMigration? {
AnyStorageMigration(
SimpleLegacyMigration(
destinationKey: self,
sourceKey: .key(LegacyKey())
)
)
}
}
}
```
### 2. Protocol-Based Migration (Recommended)
For complex migrations, implement `StorageMigration` and attach it to the key.
```swift
struct TokenMigration: StorageMigration {
typealias DestinationKey = StorageKeys.UserTokenKey
let destinationKey = StorageKeys.UserTokenKey()
let legacyKey = StorageKeys.LegacyTokenKey()
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
try await router.exists(legacyKey)
}
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
let legacyToken = try await router.get(legacyKey)
try await router.set(legacyToken, for: destinationKey)
try await router.remove(legacyKey)
return MigrationResult(success: true, migratedCount: 1)
}
}
extension StorageKeys.UserTokenKey {
var migration: AnyStorageMigration? {
AnyStorageMigration(TokenMigration())
}
}
```
### 3. 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())
try await StorageRouter.shared.forceMigration(for: StorageKeys.ModernKey())
```
#### Automated Startup Sweep

View File

@ -0,0 +1,30 @@
import Foundation
/// Conditional migration for app version-based migration.
public struct AppVersionConditionalMigration<Destination: StorageKey>: ConditionalMigration {
public typealias DestinationKey = Destination
public let destinationKey: Destination
public let minAppVersion: String
public let fallbackMigration: AnyStorageMigration
public init(
destinationKey: Destination,
minAppVersion: String,
fallbackMigration: AnyStorageMigration
) {
self.destinationKey = destinationKey
self.minAppVersion = minAppVersion
self.fallbackMigration = fallbackMigration
}
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
let isEligible = context.appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending
guard isEligible else { return false }
return try await router.shouldAllowMigration(for: destinationKey, context: context)
}
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
try await fallbackMigration.migrate(using: router, context: context)
}
}

View File

@ -0,0 +1,79 @@
import Foundation
public struct DefaultAggregatingMigration<Destination: StorageKey>: AggregatingMigration {
public typealias DestinationKey = Destination
public let destinationKey: Destination
public let sourceKeys: [AnyStorageKey]
public let aggregateAction: ([AnyCodable]) async throws -> Destination.Value
public init(
destinationKey: Destination,
sourceKeys: [AnyStorageKey],
aggregate: @escaping ([AnyCodable]) async throws -> Destination.Value
) {
self.destinationKey = destinationKey
self.sourceKeys = sourceKeys
self.aggregateAction = aggregate
}
public func aggregate(_ sources: [AnyCodable]) async throws -> Destination.Value {
try await aggregateAction(sources)
}
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
return false
}
if try await router.exists(destinationKey) {
return false
}
for source in sourceKeys {
if try await router.exists(descriptor: source.descriptor) {
return true
}
}
return false
}
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
let startTime = Date()
var sourceData: [AnyCodable] = []
var migratedCount = 0
do {
for source in sourceKeys {
if let data = try await router.retrieve(for: source.descriptor) {
let unsecuredData = try await router.applySecurity(
data,
for: source.descriptor,
isEncrypt: false
)
if let codable = try? JSONDecoder().decode(AnyCodable.self, from: unsecuredData) {
sourceData.append(codable)
migratedCount += 1
}
}
}
let aggregatedData = try await aggregate(sourceData)
try await router.set(aggregatedData, for: destinationKey)
for source in sourceKeys {
try? await router.delete(for: source.descriptor)
}
let duration = Date().timeIntervalSince(startTime)
return MigrationResult(success: true, migratedCount: migratedCount, duration: duration)
} catch {
let duration = Date().timeIntervalSince(startTime)
return MigrationResult(
success: false,
errors: [.aggregationFailed(error.localizedDescription)],
duration: duration
)
}
}
}

View File

@ -0,0 +1,56 @@
import Foundation
public struct DefaultTransformingMigration<Source: StorageKey, Destination: StorageKey>: TransformingMigration {
public typealias SourceKey = Source
public typealias DestinationKey = Destination
public let destinationKey: Destination
public let sourceKey: Source
public let transformAction: (Source.Value) async throws -> Destination.Value
public init(
destinationKey: Destination,
sourceKey: Source,
transform: @escaping (Source.Value) async throws -> Destination.Value
) {
self.destinationKey = destinationKey
self.sourceKey = sourceKey
self.transformAction = transform
}
public func transform(_ source: Source.Value) async throws -> Destination.Value {
try await transformAction(source)
}
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
return false
}
if try await router.exists(destinationKey) {
return false
}
return try await router.exists(sourceKey)
}
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
let startTime = Date()
do {
let sourceData = try await router.get(sourceKey)
let transformedData = try await transform(sourceData)
try await router.set(transformedData, for: destinationKey)
try await router.remove(sourceKey)
let duration = Date().timeIntervalSince(startTime)
return MigrationResult(success: true, migratedCount: 1, duration: duration)
} catch {
let duration = Date().timeIntervalSince(startTime)
return MigrationResult(
success: false,
errors: [.transformationFailed(error.localizedDescription)],
duration: duration
)
}
}
}

View File

@ -0,0 +1,62 @@
import Foundation
/// Simple 1:1 legacy migration.
public struct SimpleLegacyMigration<Destination: StorageKey>: StorageMigration {
public typealias DestinationKey = Destination
public let destinationKey: Destination
public let sourceKey: AnyStorageKey
public init(destinationKey: Destination, sourceKey: AnyStorageKey) {
self.destinationKey = destinationKey
self.sourceKey = sourceKey
}
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
return false
}
if try await router.exists(destinationKey) {
return false
}
return try await router.exists(descriptor: sourceKey.descriptor)
}
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
let startTime = Date()
var errors: [MigrationError] = []
var migratedCount = 0
do {
guard let sourceData = try await router.retrieve(for: sourceKey.descriptor) else {
return MigrationResult(success: false, errors: [.sourceDataNotFound])
}
let unsecuredData = try await router.applySecurity(
sourceData,
for: sourceKey.descriptor,
isEncrypt: false
)
let value = try router.deserialize(unsecuredData, with: destinationKey.serializer)
try await router.set(value, for: destinationKey)
try await router.delete(for: sourceKey.descriptor)
migratedCount = 1
Logger.info("!!! [MIGRATION] Successfully migrated from '\(sourceKey.descriptor.name)' to '\(destinationKey.name)'")
} catch {
let storageError = error as? StorageError ?? .fileError(error.localizedDescription)
errors.append(.storageFailed(storageError))
Logger.error("!!! [MIGRATION] Failed to migrate from '\(sourceKey.descriptor.name)': \(error)")
}
let duration = Date().timeIntervalSince(startTime)
return MigrationResult(
success: errors.isEmpty,
migratedCount: migratedCount,
errors: errors,
duration: duration
)
}
}

View File

@ -1,16 +1,23 @@
public struct AnyStorageKey: Sendable {
public internal(set) var descriptor: StorageKeyDescriptor
public internal(set) var migration: AnyStorageMigration?
private let migrateAction: @Sendable (StorageRouter) async throws -> Void
public init<Key: StorageKey>(_ key: Key) {
self.descriptor = .from(key)
self.migration = key.migration
self.migrateAction = { router in
try await router.migrate(for: key)
_ = try await router.forceMigration(for: key)
}
}
private init(descriptor: StorageKeyDescriptor, migrateAction: @escaping @Sendable (StorageRouter) async throws -> Void) {
private init(
descriptor: StorageKeyDescriptor,
migration: AnyStorageMigration?,
migrateAction: @escaping @Sendable (StorageRouter) async throws -> Void
) {
self.descriptor = descriptor
self.migration = migration
self.migrateAction = migrateAction
}
@ -20,7 +27,11 @@ public struct AnyStorageKey: Sendable {
/// Internal use: Returns a copy of this key with the catalog name set.
internal func withCatalog(_ name: String) -> AnyStorageKey {
AnyStorageKey(descriptor: descriptor.withCatalog(name), migrateAction: migrateAction)
AnyStorageKey(
descriptor: descriptor.withCatalog(name),
migration: migration,
migrateAction: migrateAction
)
}
/// Internal use: Triggers the migration logic for this key.

View File

@ -0,0 +1,23 @@
import Foundation
/// Type-erased wrapper for StorageMigration to match AnyStorageKey patterns.
public struct AnyStorageMigration: Sendable {
public let destinationDescriptor: StorageKeyDescriptor
private let shouldMigrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> Bool
private let migrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> MigrationResult
public init<M: StorageMigration>(_ migration: M) {
self.destinationDescriptor = .from(migration.destinationKey)
self.shouldMigrateAction = migration.shouldMigrate
self.migrateAction = migration.migrate
}
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
try await shouldMigrateAction(router, context)
}
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
try await migrateAction(router, context)
}
}

View File

@ -0,0 +1,24 @@
import Foundation
/// Context information available for conditional migrations.
public struct MigrationContext: Sendable {
public let appVersion: String
public let deviceInfo: DeviceInfo
public let migrationHistory: [String: Date]
public let userPreferences: [String: AnyCodable]
public let systemInfo: SystemInfo
public init(
appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
deviceInfo: DeviceInfo = .current,
migrationHistory: [String: Date] = [:],
userPreferences: [String: AnyCodable] = [:],
systemInfo: SystemInfo = .current
) {
self.appVersion = appVersion
self.deviceInfo = deviceInfo
self.migrationHistory = migrationHistory
self.userPreferences = userPreferences
self.systemInfo = systemInfo
}
}

View File

@ -0,0 +1,36 @@
import Foundation
/// Migration-specific error types.
public enum MigrationError: Error, Sendable, Equatable {
case validationFailed(String)
case transformationFailed(String)
case storageFailed(StorageError)
case conditionalMigrationFailed
case migrationInProgress
case sourceDataNotFound
case incompatibleTypes(String)
case aggregationFailed(String)
}
extension MigrationError: LocalizedError {
public var errorDescription: String? {
switch self {
case .validationFailed(let message):
return "Migration validation failed: \(message)"
case .transformationFailed(let message):
return "Data transformation failed: \(message)"
case .storageFailed(let storageError):
return "Storage operation failed: \(storageError.localizedDescription)"
case .conditionalMigrationFailed:
return "Conditional migration criteria not met"
case .migrationInProgress:
return "Migration already in progress"
case .sourceDataNotFound:
return "No data found in migration source"
case .incompatibleTypes(let message):
return "Type incompatibility: \(message)"
case .aggregationFailed(let message):
return "Data aggregation failed: \(message)"
}
}
}

View File

@ -0,0 +1,24 @@
import Foundation
/// Result of a migration operation with detailed information.
public struct MigrationResult: Sendable {
public let success: Bool
public let migratedCount: Int
public let errors: [MigrationError]
public let metadata: [String: AnyCodable]
public let duration: TimeInterval
public init(
success: Bool,
migratedCount: Int = 0,
errors: [MigrationError] = [],
metadata: [String: AnyCodable] = [:],
duration: TimeInterval = 0
) {
self.success = success
self.migratedCount = migratedCount
self.errors = errors
self.metadata = metadata
self.duration = duration
}
}

View File

@ -6,3 +6,16 @@ public enum PlatformAvailability: Sendable {
case watchOnly // Watch local only
case phoneWithWatchSync // Small data for explicit sync
}
public extension PlatformAvailability {
func isAvailable(on platform: Platform) -> Bool {
switch self {
case .all:
return true
case .phoneOnly, .phoneWithWatchSync:
return platform == .iOS
case .watchOnly:
return platform == .watchOS
}
}
}

View File

@ -1,6 +1,6 @@
import Foundation
public enum StorageDomain: Sendable {
public enum StorageDomain: Sendable, Equatable {
case userDefaults(suite: String?)
case appGroupUserDefaults(identifier: String?)
case keychain(service: String?)

View File

@ -0,0 +1,7 @@
import Foundation
/// Migration protocol that combines multiple sources into a single destination.
public protocol AggregatingMigration: StorageMigration {
var sourceKeys: [AnyStorageKey] { get }
func aggregate(_ sources: [AnyCodable]) async throws -> DestinationKey.Value
}

View File

@ -0,0 +1,4 @@
import Foundation
/// Marker protocol for migrations that primarily use conditional checks.
public protocol ConditionalMigration: StorageMigration {}

View File

@ -12,10 +12,9 @@ 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 }
var migration: AnyStorageMigration? { get }
}
extension StorageKey {
public var migrationSources: [AnyStorageKey] { [] }
public var migration: AnyStorageMigration? { nil }
}

View File

@ -0,0 +1,21 @@
import Foundation
/// Core migration protocol with high-level methods.
public protocol StorageMigration: Sendable {
associatedtype DestinationKey: StorageKey
/// The destination storage key where migrated data will be stored.
var destinationKey: DestinationKey { get }
/// Validate if migration should proceed (conditional logic).
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool
/// Execute the migration process.
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult
}
public extension StorageMigration {
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
try await router.shouldAllowMigration(for: destinationKey, context: context)
}
}

View File

@ -0,0 +1,9 @@
import Foundation
/// Migration protocol that supports data transformation during migration.
public protocol TransformingMigration: StorageMigration {
associatedtype SourceKey: StorageKey
var sourceKey: SourceKey { get }
func transform(_ source: SourceKey.Value) async throws -> DestinationKey.Value
}

View File

@ -1,7 +1,5 @@
import Foundation
import WatchConnectivity
/// The main storage router that coordinates all storage operations.
/// Uses specialized helper actors for each storage domain.
public actor StorageRouter: StorageProviding {
@ -10,6 +8,7 @@ public actor StorageRouter: StorageProviding {
private var catalogRegistries: [String: [AnyStorageKey]] = [:]
private var registeredKeys: [String: AnyStorageKey] = [:]
private var migrationHistory: [String: Date] = [:]
private var storageConfiguration: StorageConfiguration = .default
private let keychain: KeychainStoring
private let encryption: EncryptionHelper
@ -127,6 +126,11 @@ public actor StorageRouter: StorageProviding {
Logger.debug("<<< [STORAGE] GLOBAL MIGRATION SWEEP COMPLETE")
}
/// Returns the last migration date for a specific key, if available.
public func migrationHistory<Key: StorageKey>(for key: Key) -> Date? {
migrationHistory[key.name]
}
// MARK: - StorageProviding Implementation
/// Stores a value for the given key.
@ -164,59 +168,40 @@ public actor StorageRouter: StorageProviding {
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
}
// 2. Attempt migration (legacy or advanced)
if let migratedValue = try await attemptMigration(for: key) {
return migratedValue
}
Logger.debug("<<< [STORAGE] GET NOT FOUND: \(key.name)")
throw StorageError.notFound
}
/// Manually triggers migration from legacy sources for a specific key.
/// Manually triggers migration 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 {
/// - Returns: The migration result.
/// - Throws: Migration or storage errors.
public func forceMigration<Key: StorageKey>(for key: Key) async throws -> MigrationResult {
Logger.debug(">>> [STORAGE] MANUAL MIGRATION: \(key.name)")
try validateCatalogRegistration(for: key)
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)'")
guard let migration = resolveMigration(for: key) else {
Logger.debug("<<< [STORAGE] MANUAL MIGRATION: No migration configured for \(key.name)")
return MigrationResult(success: true, migratedCount: 0)
}
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)
}
let context = buildMigrationContext()
guard try await shouldAllowMigration(for: key, context: context) else {
Logger.debug("<<< [STORAGE] MANUAL MIGRATION: Skipped for \(key.name) on this platform")
return MigrationResult(success: true, migratedCount: 0)
}
let result = try await migration.migrate(using: self, context: context)
if result.success {
recordMigration(for: .from(key))
}
Logger.debug("<<< [STORAGE] MANUAL MIGRATION COMPLETE: \(key.name)")
return result
}
/// Removes the value for the given key.
@ -256,9 +241,31 @@ public actor StorageRouter: StorageProviding {
}
}
internal func exists(descriptor: StorageKeyDescriptor) async throws -> Bool {
switch descriptor.domain {
case .userDefaults(let suite):
return try await defaults.exists(forKey: descriptor.name, suite: suite)
case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier)
return try await defaults.exists(forKey: descriptor.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
let resolvedService = try resolveService(service)
return try await keychain.exists(service: resolvedService, key: descriptor.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
return await file.exists(in: directory, fileName: descriptor.name)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
return await file.exists(
in: directory,
fileName: descriptor.name,
appGroupIdentifier: resolvedId
)
}
}
// MARK: - Platform Validation
private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws {
nonisolated private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws {
#if os(watchOS)
if key.availability == .phoneOnly {
throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name)
@ -322,6 +329,58 @@ public actor StorageRouter: StorageProviding {
}
}
// MARK: - Migration
private func attemptMigration<Key: StorageKey>(for key: Key) async throws -> Key.Value? {
guard let migration = resolveMigration(for: key) else { return nil }
let context = buildMigrationContext()
let shouldMigrate = try await migration.shouldMigrate(using: self, context: context)
guard shouldMigrate else { return nil }
let result = try await migration.migrate(using: self, context: context)
if result.success {
recordMigration(for: .from(key))
if let securedData = try await retrieve(for: .from(key)) {
let data = try await applySecurity(securedData, for: .from(key), isEncrypt: false)
return try deserialize(data, with: key.serializer)
}
return nil
}
if let error = result.errors.first {
throw error
}
return nil
}
private func resolveMigration<Key: StorageKey>(for key: Key) -> AnyStorageMigration? {
key.migration
}
internal func buildMigrationContext() -> MigrationContext {
MigrationContext(migrationHistory: migrationHistory)
}
internal func recordMigration(for descriptor: StorageKeyDescriptor) {
migrationHistory[descriptor.name] = Date()
}
internal func shouldAllowMigration<Key: StorageKey>(
for key: Key,
context: MigrationContext
) async throws -> Bool {
guard key.availability.isAvailable(on: context.deviceInfo.platform) else {
return false
}
if key.syncPolicy != .never {
let isSyncAvailable = await sync.isSyncAvailable()
guard isSyncAvailable else { return false }
}
return true
}
// MARK: - Serialization
private func serialize<Value: Codable & Sendable>(
@ -335,7 +394,7 @@ public actor StorageRouter: StorageProviding {
}
}
private func deserialize<Value: Codable & Sendable>(
nonisolated internal func deserialize<Value: Codable & Sendable>(
_ data: Data,
with serializer: Serializer<Value>
) throws -> Value {
@ -348,7 +407,7 @@ public actor StorageRouter: StorageProviding {
// MARK: - Security
private func applySecurity(
internal func applySecurity(
_ data: Data,
for descriptor: StorageKeyDescriptor,
isEncrypt: Bool
@ -435,7 +494,7 @@ public actor StorageRouter: StorageProviding {
}
}
private func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
internal func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
switch descriptor.domain {
case .userDefaults(let suite):
return try await defaults.get(forKey: descriptor.name, suite: suite)
@ -459,7 +518,7 @@ public actor StorageRouter: StorageProviding {
}
}
private func delete(for descriptor: StorageKeyDescriptor) async throws {
internal func delete(for descriptor: StorageKeyDescriptor) async throws {
switch descriptor.domain {
case .userDefaults(let suite):
try await defaults.remove(forKey: descriptor.name, suite: suite)

View File

@ -0,0 +1,43 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
#if os(watchOS)
import WatchKit
#endif
/// Device information for migration context.
public struct DeviceInfo: Sendable {
public let platform: Platform
public let systemVersion: String
public let model: String
public let isSimulator: Bool
public static let current = DeviceInfo()
private init() {
#if os(iOS)
self.platform = .iOS
self.systemVersion = UIDevice.current.systemVersion
self.model = UIDevice.current.model
#elseif os(watchOS)
self.platform = .watchOS
self.systemVersion = WKInterfaceDevice.current().systemVersion
self.model = WKInterfaceDevice.current().model
#else
self.platform = .unknown
self.systemVersion = ProcessInfo.processInfo.operatingSystemVersionString
self.model = "Unknown"
#endif
self.isSimulator = ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil
}
public init(platform: Platform, systemVersion: String, model: String, isSimulator: Bool) {
self.platform = platform
self.systemVersion = systemVersion
self.model = model
self.isSimulator = isSimulator
}
}

View File

@ -0,0 +1,26 @@
import Foundation
/// Utilities for common migration operations.
public enum MigrationUtils {
public static func canTransform<T, U>(from: T.Type, to: U.Type) -> Bool {
if T.self is U.Type { return true }
return T.self == String.self || U.self == String.self
}
public static func estimatedSize(for data: Data) -> UInt64 {
UInt64(data.count)
}
public static func validateCompatibility(
source: StorageKeyDescriptor,
destination: StorageKeyDescriptor
) throws {
if source.name == destination.name && source.domain == destination.domain {
throw MigrationError.validationFailed("Source and destination are identical")
}
if source.valueType != destination.valueType {
throw MigrationError.incompatibleTypes("\(source.valueType) -> \(destination.valueType)")
}
}
}

View File

@ -0,0 +1,7 @@
import Foundation
public enum Platform: String, CaseIterable, Sendable {
case iOS = "iOS"
case watchOS = "watchOS"
case unknown = "unknown"
}

View File

@ -0,0 +1,17 @@
import Foundation
/// System information for migration context.
public struct SystemInfo: Sendable {
public let availableDiskSpace: UInt64
public let availableMemory: UInt64
public let isLowPowerModeEnabled: Bool
public static let current = SystemInfo()
private init() {
let systemAttributes = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
self.availableDiskSpace = (systemAttributes?[.systemFreeSize] as? UInt64) ?? 0
self.availableMemory = ProcessInfo.processInfo.physicalMemory
self.isLowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled
}
}

View File

@ -30,8 +30,8 @@ import Testing
let key = StringKey(name: "test.key")
let anyKey = AnyStorageKey.key(key)
// This will call router.migrate(for: key)
// Since there are no migration sources, it just returns
// This will call router.forceMigration(for: key)
// Since there is no migration configured, it just returns
try await anyKey.migrate(on: router)
}
}

View File

@ -0,0 +1,176 @@
import Foundation
import Testing
@testable import LocalData
private struct LegacyStringKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner: String = "Legacy"
let description: String = "Legacy string key"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
}
private struct ModernStringKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner: String = "Modern"
let description: String = "Modern string key"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
let legacyKey: AnyStorageKey?
var migration: AnyStorageMigration? {
guard let legacyKey else { return nil }
return AnyStorageMigration(
SimpleLegacyMigration(destinationKey: self, sourceKey: legacyKey)
)
}
}
private struct PhoneOnlyKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner: String = "PhoneOnly"
let description: String = "Phone-only key"
let availability: PlatformAvailability = .phoneOnly
let syncPolicy: SyncPolicy = .never
}
private struct SourceStringKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner: String = "Source"
let description: String = "Source key"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
}
private struct DestinationIntKey: StorageKey {
typealias Value = Int
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .none
let serializer: Serializer<Int> = .json
let owner: String = "Destination"
let description: String = "Destination int key"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
}
@Suite(.serialized)
struct MigrationAdditionalTests {
private let router: StorageRouter
init() {
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "MigrationAdditionalTests-\(UUID().uuidString)")
router = StorageRouter(
keychain: MockKeychainHelper(),
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "MigrationAdditionalTests.\(UUID().uuidString)")!)
)
}
@Test func migrationHistoryTrackingTest() async throws {
let legacyKey = LegacyStringKey(name: "legacy.history", domain: .userDefaults(suite: nil))
let modernKey = ModernStringKey(
name: "modern.history",
domain: .userDefaults(suite: nil),
legacyKey: .key(legacyKey)
)
try await router.set("history", for: legacyKey)
_ = try await router.forceMigration(for: modernKey)
let history = router.migrationHistory(for: modernKey)
#expect(history != nil)
}
@Test func migrationFailureKeepsSourceTest() async throws {
let sourceKey = SourceStringKey(name: "legacy.rollback", domain: .userDefaults(suite: nil))
let destinationKey = DestinationIntKey(name: "modern.rollback", domain: .userDefaults(suite: nil))
try await router.set("not-a-number", for: sourceKey)
let migration = DefaultTransformingMigration(
destinationKey: destinationKey,
sourceKey: sourceKey
) { _ in
throw MigrationError.transformationFailed("Invalid integer")
}
let result = try await migration.migrate(using: router, context: MigrationContext())
#expect(result.success == false)
#expect(try await router.exists(sourceKey) == true)
#expect(try await router.exists(destinationKey) == false)
}
@Test func watchAvailabilityBlocksMigrationTest() async throws {
let legacyKey = LegacyStringKey(name: "legacy.watch", domain: .userDefaults(suite: nil))
let destinationKey = PhoneOnlyKey(name: "modern.watch", domain: .userDefaults(suite: nil))
let migration = SimpleLegacyMigration(destinationKey: destinationKey, sourceKey: .key(legacyKey))
let deviceInfo = DeviceInfo(
platform: .watchOS,
systemVersion: "10.0",
model: "Watch",
isSimulator: true
)
let context = MigrationContext(deviceInfo: deviceInfo)
let shouldMigrate = try await migration.shouldMigrate(using: router, context: context)
#expect(shouldMigrate == false)
}
@Test func largeDataMigrationTest() async throws {
let legacyKey = LegacyStringKey(name: "legacy.large", domain: .userDefaults(suite: nil))
let modernKey = ModernStringKey(
name: "modern.large",
domain: .userDefaults(suite: nil),
legacyKey: .key(legacyKey)
)
let largeValue = String(repeating: "a", count: 1_200_000)
try await router.set(largeValue, for: legacyKey)
let migration = SimpleLegacyMigration(destinationKey: modernKey, sourceKey: .key(legacyKey))
let result = try await migration.migrate(using: router, context: MigrationContext())
#expect(result.success == true)
let migratedValue = try await router.get(modernKey)
#expect(migratedValue == largeValue)
}
@Test func typeErasureMigrationTest() async throws {
let legacyKey = LegacyStringKey(name: "legacy.erased", domain: .userDefaults(suite: nil))
let modernKey = ModernStringKey(
name: "modern.erased",
domain: .userDefaults(suite: nil),
legacyKey: .key(legacyKey)
)
try await router.set("erased", for: legacyKey)
let migration = AnyStorageMigration(
SimpleLegacyMigration(destinationKey: modernKey, sourceKey: .key(legacyKey))
)
let shouldMigrate = try await migration.shouldMigrate(using: router, context: MigrationContext())
#expect(shouldMigrate == true)
let result = try await migration.migrate(using: router, context: MigrationContext())
#expect(result.success == true)
}
}

View File

@ -0,0 +1,120 @@
import Foundation
import Testing
@testable import LocalData
private struct LegacyIntegrationKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner: String = "Legacy"
let description: String = "Legacy integration key"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
}
private struct ModernIntegrationKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .keychain(accessibility: .afterFirstUnlock, accessControl: .none)
let serializer: Serializer<String> = .json
let owner: String = "Modern"
let description: String = "Modern integration key"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
let legacyKey: AnyStorageKey?
var migration: AnyStorageMigration? {
guard let legacyKey else { return nil }
return AnyStorageMigration(
SimpleLegacyMigration(destinationKey: self, sourceKey: legacyKey)
)
}
}
private struct IntegrationCatalog: StorageKeyCatalog {
let allKeys: [AnyStorageKey]
}
@Suite(.serialized)
struct MigrationIntegrationTests {
private let router: StorageRouter
init() {
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "MigrationIntegrationTests-\(UUID().uuidString)")
router = StorageRouter(
keychain: MockKeychainHelper(),
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "MigrationIntegrationTests.\(UUID().uuidString)")!)
)
}
@Test func endToEndMigrationTest() async throws {
let legacyKey = LegacyIntegrationKey(name: "legacy.integration", domain: .userDefaults(suite: nil))
let modernKey = ModernIntegrationKey(
name: "modern.integration",
domain: .keychain(service: "test.migration"),
legacyKey: .key(legacyKey)
)
try await router.set("token", for: legacyKey)
let migrated = try await router.get(modernKey)
#expect(migrated == "token")
let legacyExists = try await router.exists(legacyKey)
#expect(legacyExists == false)
}
@Test func migrationRegistrationTest() async throws {
let legacyKey = LegacyIntegrationKey(name: "legacy.catalog", domain: .userDefaults(suite: nil))
let modernKey = ModernIntegrationKey(
name: "modern.catalog",
domain: .keychain(service: "test.migration"),
legacyKey: .key(legacyKey)
)
try await router.set("catalog", for: legacyKey)
let catalog = IntegrationCatalog(allKeys: [.key(modernKey)])
try await router.registerCatalog(catalog, migrateImmediately: true)
let migrated = try await router.get(modernKey)
#expect(migrated == "catalog")
}
@Test func concurrentMigrationTest() async throws {
let legacyKey = LegacyIntegrationKey(name: "legacy.concurrent", domain: .userDefaults(suite: nil))
let modernKey = ModernIntegrationKey(
name: "modern.concurrent",
domain: .keychain(service: "test.migration"),
legacyKey: .key(legacyKey)
)
try await router.set("value", for: legacyKey)
async let first: MigrationResult = router.forceMigration(for: modernKey)
async let second: MigrationResult = router.forceMigration(for: modernKey)
let firstResult = try await first
let secondResult = try await second
let results = [firstResult, secondResult]
#expect(results.contains(where: { $0.success }))
}
@Test func migrationFailureResultTest() async throws {
let destinationKey = ModernIntegrationKey(
name: "modern.failure",
domain: .userDefaults(suite: nil),
legacyKey: nil
)
let migration = FailingMigration(destinationKey: destinationKey, error: .validationFailed("Invalid"))
let result = try await migration.migrate(using: router, context: MigrationContext())
#expect(result.success == false)
#expect(result.errors.first == .validationFailed("Invalid"))
}
}

View File

@ -0,0 +1,161 @@
import Foundation
import Testing
@testable import LocalData
private struct LegacyStringKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner: String = "Legacy"
let description: String = "Legacy key"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
}
private struct DestinationStringKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .keychain(accessibility: .afterFirstUnlock, accessControl: .none)
let serializer: Serializer<String> = .json
let owner: String = "Destination"
let description: String = "Destination key"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
}
private struct SourceStringKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner: String = "Source"
let description: String = "Source key"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
}
private struct DestinationIntKey: StorageKey {
typealias Value = Int
let name: String
let domain: StorageDomain
let security: SecurityPolicy = .none
let serializer: Serializer<Int> = .json
let owner: String = "Destination"
let description: String = "Destination int key"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
}
@Suite(.serialized)
struct MigrationProtocolTests {
private let router: StorageRouter
init() {
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "MigrationProtocolTests-\(UUID().uuidString)")
router = StorageRouter(
keychain: MockKeychainHelper(),
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "MigrationProtocolTests.\(UUID().uuidString)")!)
)
}
@Test func simpleLegacyMigrationTest() async throws {
let legacyKey = LegacyStringKey(name: "legacy.simple", domain: .userDefaults(suite: nil))
let destinationKey = DestinationStringKey(name: "modern.simple", domain: .keychain(service: "test.migration"))
try await router.set("value", for: legacyKey)
let migration = SimpleLegacyMigration(destinationKey: destinationKey, sourceKey: .key(legacyKey))
let context = MigrationContext(appVersion: "1.0")
let shouldMigrate = try await migration.shouldMigrate(using: router, context: context)
#expect(shouldMigrate == true)
let result = try await migration.migrate(using: router, context: context)
#expect(result.success == true)
let migratedValue = try await router.get(destinationKey)
#expect(migratedValue == "value")
}
@Test func conditionalMigrationTest() async throws {
let legacyKey = LegacyStringKey(name: "legacy.conditional", domain: .userDefaults(suite: nil))
let destinationKey = DestinationStringKey(name: "modern.conditional", domain: .keychain(service: "test.migration"))
try await router.set("value", for: legacyKey)
let fallback = AnyStorageMigration(
SimpleLegacyMigration(destinationKey: destinationKey, sourceKey: .key(legacyKey))
)
let migration = AppVersionConditionalMigration(
destinationKey: destinationKey,
minAppVersion: "2.0",
fallbackMigration: fallback
)
let context = MigrationContext(appVersion: "1.0")
let shouldMigrate = try await migration.shouldMigrate(using: router, context: context)
#expect(shouldMigrate == true)
let result = try await migration.migrate(using: router, context: context)
#expect(result.success == true)
}
@Test func transformingMigrationTest() async throws {
let sourceKey = SourceStringKey(name: "legacy.transform", domain: .userDefaults(suite: nil))
let destinationKey = DestinationIntKey(name: "modern.transform", domain: .userDefaults(suite: nil))
try await router.set("42", for: sourceKey)
let migration = DefaultTransformingMigration(
destinationKey: destinationKey,
sourceKey: sourceKey
) { value in
guard let intValue = Int(value) else {
throw MigrationError.transformationFailed("Invalid integer")
}
return intValue
}
let result = try await migration.migrate(using: router, context: MigrationContext())
#expect(result.success == true)
let migratedValue = try await router.get(destinationKey)
#expect(migratedValue == 42)
}
@Test func aggregatingMigrationTest() async throws {
let sourceKeyA = SourceStringKey(name: "legacy.aggregate.a", domain: .userDefaults(suite: nil))
let sourceKeyB = SourceStringKey(name: "legacy.aggregate.b", domain: .userDefaults(suite: nil))
let destinationKey = DestinationStringKey(name: "modern.aggregate", domain: .userDefaults(suite: nil))
try await router.set("alpha", for: sourceKeyA)
try await router.set("beta", for: sourceKeyB)
let migration = DefaultAggregatingMigration(
destinationKey: destinationKey,
sourceKeys: [.key(sourceKeyA), .key(sourceKeyB)]
) { sources in
let values = sources.compactMap { $0.value as? String }
return values.joined(separator: ",")
}
let result = try await migration.migrate(using: router, context: MigrationContext())
#expect(result.success == true)
let migratedValue = try await router.get(destinationKey)
#expect(migratedValue.contains("alpha"))
#expect(migratedValue.contains("beta"))
}
@Test func migrationErrorHandlingTest() async throws {
let destinationKey = DestinationStringKey(name: "modern.error", domain: .userDefaults(suite: nil))
let migration = FailingMigration(destinationKey: destinationKey, error: .transformationFailed("Failed"))
let result = try await migration.migrate(using: router, context: MigrationContext())
#expect(result.success == false)
#expect(result.errors.first == .transformationFailed("Failed"))
}
}

View File

@ -24,12 +24,19 @@ private struct ModernKey: StorageKey {
let description: String = "Modern key"
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
let migrationSources: [AnyStorageKey]
let legacyKey: AnyStorageKey?
init(name: String, domain: StorageDomain, migrationSources: [AnyStorageKey]) {
init(name: String, domain: StorageDomain, legacyKey: AnyStorageKey?) {
self.name = name
self.domain = domain
self.migrationSources = migrationSources
self.legacyKey = legacyKey
}
var migration: AnyStorageMigration? {
guard let legacyKey else { return nil }
return AnyStorageMigration(
SimpleLegacyMigration(destinationKey: self, sourceKey: legacyKey)
)
}
}
@ -69,7 +76,7 @@ struct MigrationTests {
let modernKey = ModernKey(
name: modernName,
domain: .keychain(service: "test.migration"),
migrationSources: [.key(legacyKey)]
legacyKey: .key(legacyKey)
)
// 3. Trigger automatic migration via GET
@ -104,11 +111,11 @@ struct MigrationTests {
let modernKey = ModernKey(
name: modernName,
domain: .userDefaults(suite: suiteName),
migrationSources: [.key(legacyKey)]
legacyKey: .key(legacyKey)
)
// 3. Trigger manual migration
try await router.migrate(for: modernKey)
_ = try await router.forceMigration(for: modernKey)
// 4. Verify
let hasModern = try await router.exists(modernKey)

View File

@ -0,0 +1,42 @@
import Foundation
@testable import LocalData
struct MockMigration<Destination: StorageKey>: StorageMigration {
typealias DestinationKey = Destination
let destinationKey: Destination
let shouldSucceed: Bool
let shouldMigrateResult: Bool
let migrationDelay: TimeInterval
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
try await Task.sleep(for: .seconds(0.1))
return shouldMigrateResult
}
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
try await Task.sleep(for: .seconds(migrationDelay))
if shouldSucceed {
return MigrationResult(success: true, migratedCount: 1)
}
return MigrationResult(
success: false,
errors: [.transformationFailed("Mock failure")]
)
}
}
struct FailingMigration<Destination: StorageKey>: StorageMigration {
typealias DestinationKey = Destination
let destinationKey: Destination
let error: MigrationError
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool { true }
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
MigrationResult(success: false, errors: [error])
}
}