From 70534f647b59798bc09f686738b29870878a0ca4 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 16 Jan 2026 13:47:12 -0600 Subject: [PATCH] Update Migrations, Models, Protocols (+2 more) and tests, docs Summary: - Sources: update Migrations, Models, Protocols (+2 more) - Tests: update tests for AnyStorageKeyTests.swift, MigrationAdditionalTests.swift, MigrationIntegrationTests.swift (+3 more) - Docs: update docs for Migration, Migration_Refactor_Plan_Clean, Proposal (+1 more) Stats: - 31 files changed, 2820 insertions(+), 80 deletions(-) --- Documentation/Migration.md | 49 +- .../Migration_Refactor_Plan_Clean.md | 1595 +++++++++++++++++ Proposal.md | 3 +- README.md | 69 +- .../AppVersionConditionalMigration.swift | 30 + .../DefaultAggregatingMigration.swift | 79 + .../DefaultTransformingMigration.swift | 56 + .../Migrations/SimpleLegacyMigration.swift | 62 + Sources/LocalData/Models/AnyStorageKey.swift | 17 +- .../Models/AnyStorageMigration.swift | 23 + .../LocalData/Models/MigrationContext.swift | 24 + Sources/LocalData/Models/MigrationError.swift | 36 + .../LocalData/Models/MigrationResult.swift | 24 + .../Models/PlatformAvailability.swift | 13 + Sources/LocalData/Models/StorageDomain.swift | 2 +- .../Protocols/AggregatingMigration.swift | 7 + .../Protocols/ConditionalMigration.swift | 4 + Sources/LocalData/Protocols/StorageKey.swift | 5 +- .../Protocols/StorageMigration.swift | 21 + .../Protocols/TransformingMigration.swift | 9 + .../LocalData/Services/StorageRouter.swift | 157 +- Sources/LocalData/Utilities/DeviceInfo.swift | 43 + .../LocalData/Utilities/MigrationUtils.swift | 26 + Sources/LocalData/Utilities/Platform.swift | 7 + Sources/LocalData/Utilities/SystemInfo.swift | 17 + Tests/LocalDataTests/AnyStorageKeyTests.swift | 4 +- .../MigrationAdditionalTests.swift | 176 ++ .../MigrationIntegrationTests.swift | 120 ++ .../MigrationProtocolTests.swift | 161 ++ Tests/LocalDataTests/MigrationTests.swift | 19 +- .../LocalDataTests/Mocks/MockMigration.swift | 42 + 31 files changed, 2820 insertions(+), 80 deletions(-) create mode 100644 Documentation/Migration_Refactor_Plan_Clean.md create mode 100644 Sources/LocalData/Migrations/AppVersionConditionalMigration.swift create mode 100644 Sources/LocalData/Migrations/DefaultAggregatingMigration.swift create mode 100644 Sources/LocalData/Migrations/DefaultTransformingMigration.swift create mode 100644 Sources/LocalData/Migrations/SimpleLegacyMigration.swift create mode 100644 Sources/LocalData/Models/AnyStorageMigration.swift create mode 100644 Sources/LocalData/Models/MigrationContext.swift create mode 100644 Sources/LocalData/Models/MigrationError.swift create mode 100644 Sources/LocalData/Models/MigrationResult.swift create mode 100644 Sources/LocalData/Protocols/AggregatingMigration.swift create mode 100644 Sources/LocalData/Protocols/ConditionalMigration.swift create mode 100644 Sources/LocalData/Protocols/StorageMigration.swift create mode 100644 Sources/LocalData/Protocols/TransformingMigration.swift create mode 100644 Sources/LocalData/Utilities/DeviceInfo.swift create mode 100644 Sources/LocalData/Utilities/MigrationUtils.swift create mode 100644 Sources/LocalData/Utilities/Platform.swift create mode 100644 Sources/LocalData/Utilities/SystemInfo.swift create mode 100644 Tests/LocalDataTests/MigrationAdditionalTests.swift create mode 100644 Tests/LocalDataTests/MigrationIntegrationTests.swift create mode 100644 Tests/LocalDataTests/MigrationProtocolTests.swift create mode 100644 Tests/LocalDataTests/Mocks/MockMigration.swift diff --git a/Documentation/Migration.md b/Documentation/Migration.md index c85e0f5..bbee2be 100644 --- a/Documentation/Migration.md +++ b/Documentation/Migration.md @@ -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()) } } ``` diff --git a/Documentation/Migration_Refactor_Plan_Clean.md b/Documentation/Migration_Refactor_Plan_Clean.md new file mode 100644 index 0000000..9e314a7 --- /dev/null +++ b/Documentation/Migration_Refactor_Plan_Clean.md @@ -0,0 +1,1595 @@ +# Migration Refactor Plan + +## Overview + +Replace the current `StorageKey.migrationSources` approach with a protocol-based migration system that supports conditional logic, data transformation, and complex migration scenarios. + +### Design Guardrails (Align with Current Patterns) +- Keep `StorageKey` as the single source of truth for key metadata. +- Use type erasure (`AnyStorageKey`-style) for heterogeneous migrations. +- Avoid `any StorageKey` existentials in public APIs; prefer `AnyStorageKey` and `StorageKeyDescriptor`. +- Migrations must use the router instance passed in (no `StorageRouter.shared` inside migration logic). + +### File Organization +Follow AGENTS.md Clean Architecture and File Organization Principles for all new code: +- **One Public Type Per File**: Each file contains exactly one public struct/class/enum (private helpers OK if small). +- **Keep Files Lean (<300 Lines)**: Extract complex logic to dedicated types if needed (e.g., separate files for each concrete migration). +- **No Duplicate Code**: Search for existing patterns before writing (e.g., reuse AnyStorageKey patterns). +- **Logical Grouping**: Organize by feature, not type: + ``` + Sources/LocalData/ + ├── Migrations/ # New folder for concrete migrations + │ ├── SimpleLegacyMigration.swift + │ ├── TransformingMigration.swift + │ └── AggregatingMigration.swift + ├── Models/ # Data models + │ ├── AnyStorageMigration.swift + │ ├── MigrationResult.swift + │ └── MigrationContext.swift + ├── Protocols/ # Interfaces + │ ├── StorageMigration.swift + │ ├── TransformingMigration.swift + │ └── AggregatingMigration.swift + └── Utilities/ # Helpers + ├── DeviceInfo.swift + └── SystemInfo.swift + ``` +- Ensure documentation files (e.g., Migration.md) stay in sync with changes. + +## Current System Analysis + +### Limitations of Current System +- Strict 1:Many legacy-to-new key migration (last one wins) +- No conditional logic based on content or state +- No data transformation capabilities during migration +- Cannot aggregate multiple sources +- Hardcoded migration logic in StorageRouter +- Limited error handling and recovery options + +### Strengths to Preserve +- Simple API for basic migrations +- Automatic fallback on `get()` operations +- Manual migration sweep support +- Integration with storage catalog system +- Comprehensive test coverage + +## Phase 1: Core Protocol Infrastructure + +### 1.1 New Protocol Files + +#### `Sources/LocalData/Protocols/StorageMigration.swift` +```swift +/// 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 +} +``` + + +#### `Sources/LocalData/Models/AnyStorageMigration.swift` +```swift +/// 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(_ 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) + } +} +``` + +#### `Sources/LocalData/Models/MigrationResult.swift` +```swift +/// 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 + } +} + +/// 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) + + public var localizedDescription: 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)" + } + } +} +``` + +#### `Sources/LocalData/Models/MigrationContext.swift` +```swift +/// 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 + } +} +``` + +### 1.2 Update StorageKey Protocol + +#### Modify `Sources/LocalData/Protocols/StorageKey.swift` +```swift +public protocol StorageKey: Sendable, CustomStringConvertible { + associatedtype Value: Codable & Sendable + + var name: String { get } + var domain: StorageDomain { get } + var security: SecurityPolicy { get } + var serializer: Serializer { get } + var owner: String { get } + var availability: PlatformAvailability { get } + var syncPolicy: SyncPolicy { get } + + /// Optional migration for simple or complex scenarios + var migration: AnyStorageMigration? { get } +} + +extension StorageKey { + public var migration: AnyStorageMigration? { nil } +} +``` + +### 1.3 Update AnyStorageKey + +#### Modify `Sources/LocalData/Models/AnyStorageKey.swift` +```swift +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: Key) { + self.descriptor = .from(key) + self.migration = key.migration + self.migrateAction = { router in + _ = try await router.forceMigration(for: key) + } + } + + // ... existing initializers/withCatalog/migrate(on:) ... +} +``` + +## Phase 2: Concrete Migration Implementations + +### 2.1 Legacy Support Migration + +#### `Sources/LocalData/Migrations/SimpleLegacyMigration.swift` +```swift +/// Simple 1:1 legacy migration +public struct SimpleLegacyMigration: 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 { + // Check if destination is empty and legacy source has data + 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 metadata: [String: AnyCodable] = [:] + var migratedCount = 0 + + do { + if let sourceData = try await router.retrieve(for: sourceKey.descriptor) { + let unsecuredData = try await router.applySecurity(sourceData, for: sourceKey.descriptor, isEncrypt: false) + let value = try router.deserialize(unsecuredData, with: destinationKey.serializer) + + // Store using destination key configuration + try await router.set(value, for: destinationKey) + + // Delete source + try await router.delete(for: sourceKey.descriptor) + + migratedCount = 1 + Logger.info("!!! [MIGRATION] Successfully migrated from '\(sourceKey.descriptor.name)' to '\(destinationKey.descriptor.name)'") + } + } catch { + errors.append(.storageFailed(error as? StorageError ?? .unknown(error))) + 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, + metadata: metadata, + duration: duration + ) + } +} +``` + +### 2.2 Conditional Migration + +#### `Sources/LocalData/Migrations/AppVersionConditionalMigration.swift` +```swift +/// Conditional migration for app version-based migration +public struct AppVersionConditionalMigration: 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 { + return context.appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending + } + + public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult { + // Use fallback migration if condition is met + return try await fallbackMigration.migrate(using: router, context: context) + } +} +``` + +### 2.3 Transforming Migration + +#### `Sources/LocalData/Migrations/TransformingMigration.swift` +```swift +/// 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 +} + +public struct DefaultTransformingMigration: TransformingMigration { + public typealias SourceKey = Source + public typealias DestinationKey = Destination + + public let destinationKey: Destination + public let sourceKey: Source + public let transform: (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.transform = transform + } + + public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool { + try await router.exists(sourceKey) + } + + public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult { + let startTime = Date() + + do { + // Get source data + let sourceData = try await router.get(sourceKey) + + // Transform data + let transformedData = try await transform(sourceData) + + // Store transformed data + try await router.set(transformedData, for: destinationKey) + + // Delete source + 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 + ) + } + } +} +``` + +### 2.4 Aggregating Migration + +#### `Sources/LocalData/Migrations/AggregatingMigration.swift` +```swift +/// Migration protocol that combines multiple sources into single destination +public protocol AggregatingMigration: StorageMigration { + var sourceKeys: [AnyStorageKey] { get } + func aggregate(_ sources: [AnyCodable]) async throws -> DestinationKey.Value +} + +public struct DefaultAggregatingMigration: AggregatingMigration { + public typealias DestinationKey = Destination + + public let destinationKey: Destination + public let sourceKeys: [AnyStorageKey] + public let aggregate: ([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.aggregate = aggregate + } + + public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool { + // Check if any source has data and destination is empty + var hasSourceData = false + for source in sourceKeys { + if try await router.exists(descriptor: source.descriptor) { + hasSourceData = true + break + } + } + let destinationExists = try await router.exists(destinationKey) + return hasSourceData && !destinationExists + } + + public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult { + let startTime = Date() + var sourceData: [AnyCodable] = [] + var migratedCount = 0 + + do { + // Collect data from all sources + 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 + } + } + } + + // Aggregate data + let aggregatedData = try await aggregate(sourceData) + + // Store aggregated data + try await router.set(aggregatedData, for: destinationKey) + + // Delete source data + 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 + ) + } + } +} +``` + +## Phase 3: StorageRouter Integration + +### WatchOS and Sync Integration +Migrations respect key.availability and syncPolicy. Provide default implementations in StorageMigration extension to handle platform checks and post-migration sync automatically, minimizing developer complexity. + +#### Example Default Extension +```swift +extension StorageMigration { + public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool { + guard destinationKey.availability.isAvailable(on: context.deviceInfo.platform) else { return false } + if destinationKey.syncPolicy != .never { + guard await WatchConnectivityService.shared.isLeadDevice else { return false } + } + return true // Override for custom logic + } + + public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult { + // Core logic... + if destinationKey.syncPolicy != .never { + await WatchConnectivityService.shared.syncKey(destinationKey) + } + return result + } +} +``` + +### 3.1 Update StorageRouter + +#### Modify `Sources/LocalData/Services/StorageRouter.swift` + +**New Properties:** +```swift +public actor StorageRouter: StorageProviding { + // ... existing properties ... + + private var migrationHistory: [String: Date] = [:] + + // No initializer changes required +} +``` + +**Internal Migration Helpers (Visibility Updates):** +```swift +// Promote existing private helpers to internal for migration support. +internal func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? { ... } +internal func delete(for descriptor: StorageKeyDescriptor) async throws { ... } +internal func deserialize(_ data: Data, with serializer: Serializer) throws -> Value { ... } + +// New helper for type-erased existence checks. +internal func exists(descriptor: StorageKeyDescriptor) async throws -> Bool { ... } + +// Build a single context instance for migration decisions. +internal func buildMigrationContext() async -> MigrationContext { + MigrationContext(migrationHistory: migrationHistory) +} + +internal func recordMigration(for descriptor: StorageKeyDescriptor) { + migrationHistory[descriptor.name] = Date() +} +``` + +**Updated Methods:** + +**Enhanced `get` method:** +```swift +public func get(_ key: Key) async throws -> Key.Value { + try validateCatalogRegistration(for: key) + try validatePlatformAvailability(for: key) + + // 1. Check 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 + } + + // 2. NEW: Try protocol-based migration if defined + if let migration = key.migration { + let context = await buildMigrationContext() + if try await migration.shouldMigrate(using: self, context: context) { + Logger.info("!!! [MIGRATION] Starting migration for key: \(key.name)") + let result = try await advanced.migrate(using: self, context: context) + if result.success, + let finalData = try await retrieve(for: .from(key)) { + let unsecuredData = try await applySecurity(finalData, for: .from(key), isEncrypt: false) + let value = try deserialize(unsecuredData, with: key.serializer) + recordMigration(for: .from(key)) + return value + } + } + } + + Logger.debug("<<< [STORAGE] GET NOT FOUND: \(key.name)") + throw StorageError.notFound +} +``` + +**Update `migrateAllRegisteredKeys`:** +```swift +private func migrateAllRegisteredKeys() async throws { + for entry in registeredKeys.values { + guard entry.migration != nil else { continue } + try await entry.migrate(on: self) + } +} +``` + +**New Methods:** +```swift +/// Get migration history for a key +public func migrationHistory(for key: Key) async -> Date? { + migrationHistory[key.name] +} + +/// Force migration for a specific key using its attached migration +public func forceMigration(for key: Key) async throws -> MigrationResult { + guard let migration = key.migration else { + throw MigrationError.sourceDataNotFound + } + + Logger.debug(">>> [MIGRATION] FORCED MIGRATION: \(key.name)") + let context = await buildMigrationContext() + let result = try await migration.migrate(using: self, context: context) + if result.success { + recordMigration(for: .from(key)) + } + return result +} +``` + +**Catalog Registration Ordering (Multi-Catalog Safe):** +- When `migrateImmediately` is true, only attempt migrations for keys that define `migration`. +- If multiple catalogs attempt to register migrations for the same key name, treat it as a duplicate registration + and throw, matching existing duplicate key behavior. + +## Phase 4: Sample App Updates + +### 4.1 Update MigrationDemo View + +#### Modify `SecureStorageSample/Views/MigrationDemo.swift` + +**New Structure:** +```swift +@MainActor +struct MigrationDemo: View { + @State private var selectedTab = 0 + @State private var legacyValue = "" + @State private var modernValue = "" + @State private var conditionalValue = "" + @State private var transformedValue = "" + @State private var aggregatedValue = "" + @State private var statusMessage = "" + @State private var isLoading = false + @State private var migrationResults: [MigrationResult] = [] + + var body: some View { + TabView(selection: $selectedTab) { + legacyMigrationView.tabItem { + Label("Legacy", systemImage: "arrow.clock") + } + + conditionalMigrationView.tabItem { + Label("Conditional", systemImage: "questionmark.circle") + } + + transformingMigrationView.tabItem { + Label("Transform", systemImage: "arrow.triangle.2.circlepath") + } + + aggregatingMigrationView.tabItem { + Label("Aggregate", systemImage: "square.stack.3d.up") + } + } + .navigationTitle("Data Migration") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Tab Views + + @ViewBuilder + private var legacyMigrationView: some View { + // ... existing migration demo UI ... + } + + @ViewBuilder + private var conditionalMigrationView: some View { + Form { + Section("Conditional Migration") { + Text("Migrate data only if app version is older than 2.0, demonstrating conditional logic.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Current App Version") { + Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown") + .font(.title2) + .bold() + } + + Section("Test Conditional Migration") { + Button(action: testConditionalMigration) { + Label("Test Conditional Migration", systemImage: "play.circle") + } + .disabled(isLoading) + + if !conditionalValue.isEmpty { + LabeledContent("Migrated Value", value: conditionalValue) + .foregroundStyle(.green) + .bold() + } + } + } + } + + @ViewBuilder + private var transformingMigrationView: some View { + Form { + Section("Data Transformation") { + Text("Transform old UserProfileV1 format to new UserProfileV2 format, demonstrating data structure changes.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Legacy Profile") { + TextField("First Name", text: $legacyFirstName) + TextField("Last Name", text: $legacyLastName) + + Button(action: saveLegacyProfile) { + Label("Save Legacy Profile", systemImage: "person.crop.circle") + } + .disabled(legacyFirstName.isEmpty || legacyLastName.isEmpty || isLoading) + } + + Section("Transform and Migrate") { + Button(action: testTransformingMigration) { + Label("Transform and Migrate", systemImage: "arrow.triangle.2.circlepath") + } + .disabled(isLoading) + + if !transformedValue.isEmpty { + Text(transformedValue) + .font(.caption) + .foregroundStyle(.green) + } + } + } + } + + @ViewBuilder + private var aggregatingMigrationView: some View { + Form { + Section("Data Aggregation") { + Text("Combine multiple legacy settings keys into single unified settings, demonstrating multi-source aggregation.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Legacy Settings") { + // Multiple settings fields for aggregation + Toggle("Notifications", isOn: $notificationsEnabled) + Toggle("Auto-Backup", isOn: $autoBackupEnabled) + TextField("Theme", text: $selectedTheme) + + Button(action: saveLegacySettings) { + Label("Save Legacy Settings", systemImage: "gear") + } + .disabled(isLoading) + } + + Section("Aggregate and Migrate") { + Button(action: testAggregatingMigration) { + Label("Aggregate Settings", systemImage: "square.stack.3d.up") + } + .disabled(isLoading) + + if !aggregatedValue.isEmpty { + Text(aggregatedValue) + .font(.caption) + .foregroundStyle(.green) + } + } + } + } +} +``` + +### 4.2 New Migration Implementations in Sample App + +#### New Files: `SecureStorageSample/Migrations/` + +**`ConditionalProfileMigration.swift`** +```swift +import LocalData +import SharedKit + +/// Conditional migration that only runs for old app versions +struct ConditionalProfileMigration: ConditionalMigration { + typealias DestinationKey = ModernConditionalProfileKey + + let destinationKey = ModernConditionalProfileKey() + + func shouldMigrate(context: MigrationContext) async throws -> Bool { + // Only migrate if app version is older than 2.0 + return context.appVersion.compare("2.0.0", options: .numeric) == .orderedAscending + } + + func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult { + // Use simple legacy migration if condition is met + let legacyMigration = SimpleLegacyMigration( + destinationKey: destinationKey, + sourceKey: .key(LegacyConditionalProfileKey()) + ) + return try await legacyMigration.migrate(using: router, context: context) + } +} +``` + +**`ProfileTransformMigration.swift`** +```swift +/// Transform legacy UserProfileV1 to modern UserProfileV2 +struct ProfileTransformMigration: TransformingMigration { + typealias SourceKey = LegacyUserProfileV1Key + typealias DestinationKey = ModernUserProfileKey + + let destinationKey = ModernUserProfileKey() + let sourceKey = LegacyUserProfileV1Key() + + func transform(_ source: UserProfileV1) async throws -> UserProfile { + UserProfile( + id: source.id, + fullName: "\(source.firstName) \(source.lastName)", + email: source.email, + createdAt: source.createdAt, + updatedAt: Date(), + preferences: UserPreferences( + notifications: true, + theme: "system", + language: "en" + ) + ) + } +} + +// Legacy profile format +struct UserProfileV1: Codable { + let id: UUID + let firstName: String + let lastName: String + let email: String + let createdAt: Date +} +``` + +**`AggregatedSettingsMigration.swift`** +```swift +/// Aggregate multiple settings sources into unified settings +struct AggregatedSettingsMigration: AggregatingMigration { + typealias DestinationKey = UnifiedSettingsKey + + let destinationKey = UnifiedSettingsKey() + let sourceKeys: [AnyStorageKey] = [ + .key(NotificationSettingsKey()), + .key(BackupSettingsKey()), + .key(ThemeSettingsKey()) + ] + + func aggregate(_ sources: [AnyCodable]) async throws -> UnifiedSettings { + var notifications = false + var autoBackup = false + var theme = "system" + + // Extract individual settings from sources + for source in sources { + if let data = source.value as? [String: Any] { + if let notifs = data["notifications"] as? Bool { + notifications = notifs + } + if let backup = data["autoBackup"] as? Bool { + autoBackup = backup + } + if let selectedTheme = data["theme"] as? String { + theme = selectedTheme + } + } + } + + return UnifiedSettings( + notifications: notifications, + autoBackup: autoBackup, + theme: theme, + version: "2.0", + lastUpdated: Date() + ) + } +} + +struct UnifiedSettings: Codable { + let notifications: Bool + let autoBackup: Bool + let theme: String + let version: String + let lastUpdated: Date +} +``` + +### 4.3 New Storage Keys for Sample App + +#### `SecureStorageSample/StorageKeys/Migration/` + +**`ConditionalMigrationKeys.swift`** +```swift +extension StorageKeys { + struct LegacyConditionalProfileKey: StorageKey { + typealias Value = UserProfile + + let name = "legacy_conditional_profile" + let domain: StorageDomain = .userDefaults(suite: nil) + let security: SecurityPolicy = .none + let serializer: Serializer = .json + let owner = "MigrationDemo" + let description = "Legacy profile for conditional migration demo." + let availability: PlatformAvailability = .all + let syncPolicy: SyncPolicy = .never + } + + struct ModernConditionalProfileKey: StorageKey { + typealias Value = UserProfile + + let name = "modern_conditional_profile" + let domain: StorageDomain = .keychain(service: "com.mbrucedogs.securestorage") + let security: SecurityPolicy = .keychain( + accessibility: .afterFirstUnlock, + accessControl: .userPresence + ) + let serializer: Serializer = .json + let owner = "MigrationDemo" + let description = "Modern profile for conditional migration demo." + let availability: PlatformAvailability = .all + let syncPolicy: SyncPolicy = .never + + var migration: AnyStorageMigration? { + AnyStorageMigration(ConditionalProfileMigration()) + } + } +} +``` + +## Phase 6: Testing Implementation + +### Audit and Update Existing Tests +- Review and update existing MigrationTests.swift to remove references to migrationSources and test new migration patterns. +- Add tests for failure scenarios, rollbacks, and watchOS-specific cases (e.g., skipped migrations on watch). + +## Phase 7: Documentation Updates + +Move documentation updates to after testing completion. + +### 7.1 Update Migration.md +- Update the Data Migration section to describe migration for simple and complex cases. +- Replace migrationSources examples with new patterns. +- Update migrateImmediately description to handle both simple and advanced migrations. + +## Phase 8: Additional Utility Files + +### 5.1 Update Migration.md + +#### File: `Documentation/Migration.md` + +**Complete Rewrite:** +```markdown +# LocalData Migration Guide + +## Overview + +LocalData provides a flexible, protocol-based migration system that supports: + +- **Simple Legacy Migration**: Drop-in replacement for string-based key migration +- **Conditional Migration**: Execute migration based on app version, device state, or custom logic +- **Data Transformation**: Convert data structures and types during migration +- **Data Aggregation**: Combine multiple sources into single destination + +## Protocol-Based Migration (Recommended) + +### Core Migration Protocol + +The `StorageMigration` protocol provides the foundation for all migration types: + +```swift +public protocol StorageMigration: Sendable { + associatedtype DestinationKey: StorageKey + + var destinationKey: DestinationKey { get } + func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool + func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult +} + +### Migration Types + +#### 1. Simple Legacy Migration + +Simple 1:1 migration for a legacy key: + +```swift +struct LegacyToModernMigration: StorageMigration { + typealias DestinationKey = ModernUserProfileKey + + let destinationKey = ModernUserProfileKey() + let sourceKey: AnyStorageKey + + func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool { + // Check if destination empty and legacy has data + } + + func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult { + // Move data from legacy to modern + } +} + +// Register with key +extension ModernUserProfileKey { + var migration: AnyStorageMigration? { + AnyStorageMigration(SimpleLegacyMigration( + destinationKey: self, + sourceKey: .key(LegacyUserProfileKey()) + )) + } +} +``` + +#### 2. Conditional Migration + +Execute migration based on custom logic: + +```swift +struct VersionBasedMigration: ConditionalMigration { + typealias DestinationKey = ModernUserProfileKey + + let destinationKey = ModernUserProfileKey() + + func shouldMigrate(context: MigrationContext) async throws -> Bool { + // Only migrate from app versions < 2.0 + context.appVersion.compare("2.0.0", options: .numeric) == .orderedAscending + } + + func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult { + // Migration logic here + } +} +``` + +#### 3. Data Transformation Migration + +Convert data structures during migration: + +```swift +struct ProfileUpgradeMigration: TransformingMigration { + typealias SourceKey = LegacyProfileKey + typealias DestinationKey = ModernProfileKey + + let destinationKey = ModernProfileKey() + let sourceKey = LegacyProfileKey() + + func transform(_ source: ProfileV1) async throws -> ProfileV2 { + ProfileV2( + id: source.id, + fullName: "\(source.firstName) \(source.lastName)", + email: source.email, + preferences: UserPreferences.fromLegacy(source.preferences) + ) + } + + func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult { + // Get source, transform, store, cleanup + } +} +``` + +#### 4. Data Aggregation Migration + +Combine multiple sources: + +```swift +struct SettingsAggregationMigration: AggregatingMigration { + typealias DestinationKey = UnifiedSettingsKey + + let destinationKey = UnifiedSettingsKey() + let sourceKeys: [AnyStorageKey] = [ + .key(NotificationSettingsKey()), + .key(ThemeSettingsKey()), + .key(PrivacySettingsKey()) + ] + + func aggregate(_ sources: [AnyCodable]) async throws -> UnifiedSettings { + // Combine all settings into unified structure + } + + func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult { + // Collect from all sources, aggregate, store, cleanup + } +} +``` + +### Migration History + +Migration history is tracked in-memory by `StorageRouter` to support conditional logic: + +```swift +// Get last migration date +let lastMigrationDate = await StorageRouter.shared.migrationHistory(for: key) + +// Force migration +let result = try await StorageRouter.shared.forceMigration(for: key) +``` + +### 5.2 Update README.md (Post-Implementation) + +#### File: `README.md` +- Update the Data Migration section to describe the new `StorageKey.migration` flow. +- Replace `migrationSources` examples with `AnyStorageMigration` examples. +- Update `migrateImmediately` description to reflect migrations attached to keys. + +### 5.3 Update Proposal.md (Post-Implementation) + +#### File: `Proposal.md` +- Reflect the new protocol-based migration approach in the proposal narrative. +- Remove legacy migration references (`migrationSources`) from the proposal. + +### Migration Results + +All migrations return a `MigrationResult`: + +```swift +public struct MigrationResult { + public let success: Bool + public let migratedCount: Int + public let errors: [MigrationError] + public let metadata: [String: AnyCodable] + public let duration: TimeInterval +} +``` + +### Error Handling + +Comprehensive error types for migration failures: + +```swift +public enum MigrationError: Error, Sendable { + case validationFailed(String) + case transformationFailed(String) + case storageFailed(StorageError) + case conditionalMigrationFailed + case sourceDataNotFound + // ... more cases +} +``` + +## Migration Strategies + +### Automatic Migration + +Migrations trigger automatically when accessing keys with no data: + +1. Check primary location +2. Execute `shouldMigrate(using:context:)` if migration protocol exists +3. Run migration if condition is met +4. Return migrated data + +### Manual Migration + +Force migration with `forceMigration(for:)`: + +```swift +let result = try await StorageRouter.shared.forceMigration(for: key) +if result.success { + print("Migrated \(result.migratedCount) items in \(result.duration)s") +} +``` + +### Proactive Migration (Sweep) + +Migrate all registered keys at app launch: + +```swift +try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self, migrateImmediately: true) +``` + +## Best Practices + +1. **Use Protocol-Based Migration**: More flexible and powerful than legacy arrays +2. **Implement Conditional Logic**: Avoid unnecessary migrations +3. **Transform Data Early**: Migrate to modern data structures +4. **Test Migrations**: Comprehensive testing for all scenarios +5. **Handle Errors**: Provide fallbacks and recovery options +6. **Document Migrations**: Clear descriptions for audit reports + +## Testing Migrations + +Use the new protocol system for comprehensive testing: + +```swift +@Test func conditionalMigrationTest() async throws { + let migration = VersionBasedMigration() + let context = MigrationContext() + 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) +} +``` + +## Migration History + +The system tracks migration history to prevent re-migration: + +`MigrationContext` is built by `StorageRouter` when running migrations and includes in-memory +migration history. In tests, you can create a context manually with a custom history to validate +conditional behavior. + +## Performance Considerations + +- **Conditional Logic**: Fast checks before expensive operations +- **Batch Operations**: Efficient processing of multiple sources +- **Error Recovery**: Capture errors in `MigrationResult`; rollback is out of scope for Phase 1 +- **Progress Reporting**: Defer until a concrete UI/reporting requirement exists +``` + +### 5.2 Update README.md + +#### File: `README.md` + +**Add to Usage Section:** +```markdown +## Usage + +### 1. Define Keys + +Extend `StorageKeys` with your own key types: + +```swift +import LocalData + +extension StorageKeys { + struct UserTokenKey: StorageKey { + typealias Value = String + + let name = "user_token" + let domain: StorageDomain = .keychain(service: "com.myapp") + let security: SecurityPolicy = .keychain( + accessibility: .afterFirstUnlock, + accessControl: .biometryAny + ) + let serializer: Serializer = .json + let owner = "AuthService" + let description = "Stores the current user auth token." + let availability: PlatformAvailability = .phoneOnly + let syncPolicy: SyncPolicy = .never + } +} +``` + +### 2. Protocol-Based Migration (Recommended) + +For complex migration scenarios, implement the `StorageMigration` protocol: + +```swift +struct TokenMigration: StorageMigration { + typealias DestinationKey = UserTokenKey + + let destinationKey = UserTokenKey() + let legacyKey = 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) + } +} + +// Associate with key +extension UserTokenKey { + var migration: AnyStorageMigration? { + AnyStorageMigration(TokenMigration()) + } +} +``` + +### Migration Types + +| Type | Use Case | +|------|----------| +| **SimpleLegacyMigration** | Direct data movement from legacy sources | +| **ConditionalMigration** | Migrate based on app version or device state | +| **TransformingMigration** | Convert data structures during migration | +| **AggregatingMigration** | Combine multiple sources into one destination | + +### 3. Use StorageRouter + +```swift +// Save +let key = StorageKeys.UserTokenKey() +try await StorageRouter.shared.set("token123", for: key) + +// Retrieve (triggers automatic migration if needed) +let token = try await StorageRouter.shared.get(key) + +// Remove +try await StorageRouter.shared.remove(key) +``` + +### 4. Run Migration + +```swift +// Force migration +let result = try await StorageRouter.shared.forceMigration(for: UserTokenKey()) +``` +``` + +## Phase 6: Testing Implementation + +### 6.1 New Test Files + +#### `Tests/LocalDataTests/MigrationProtocolTests.swift` +```swift +@Suite(.serialized) +struct MigrationProtocolTests { + private let router: StorageRouter + + init() { + // Fresh router for each test suite + 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 { + // Test basic legacy migration behavior + } + + @Test func conditionalMigrationTest() async throws { + // Test conditional migration logic + } + + @Test func transformingMigrationTest() async throws { + // Test data transformation during migration + } + + @Test func aggregatingMigrationTest() async throws { + // Test multiple source aggregation + } + + @Test func migrationErrorHandlingTest() async throws { + // Test error scenarios and recovery + } + + @Test func migrationResultValidationTest() async throws { + // Test migration result structure + } +} +``` + +#### `Tests/LocalDataTests/MigrationIntegrationTests.swift` +```swift +@Suite(.serialized) +struct MigrationIntegrationTests { + @Test func endToEndMigrationTest() async throws { + // Test complete migration workflow + } + + @Test func migrationRegistrationTest() async throws { + // Ensure catalog registration and migration lookup work together + } + + @Test func concurrentMigrationTest() async throws { + // Test concurrent migration attempts + } + + @Test func largeDataMigrationTest() async throws { + // Test migration with large datasets + } + + @Test func migrationFailureResultTest() async throws { + // Test failure results and error reporting + } +} +``` + +### 6.2 Update Existing Tests + +#### Modify `Tests/LocalDataTests/MigrationTests.swift` + +**Preserve Existing Tests:** +- Keep all existing `MigrationTests` unchanged +- Add new tests for protocol-based migrations +- Ensure migration registration and catalog behavior stay consistent + +**New Test Methods:** +```swift +@Test func protocolBasedMigrationTest() async throws { + // Test new protocol system works +} + +@Test func migrationRegistrationTest() async throws { + // Test registration via catalog and direct API +} +``` + +### 6.3 Mock Implementations + +#### `Tests/LocalDataTests/Mocks/MockMigration.swift` +```swift +/// Mock migration for testing +struct MockMigration: 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) + } else { + return MigrationResult( + success: false, + errors: [.transformationFailed("Mock failure")] + ) + } + } +} + +/// Failing migration for error testing +struct FailingMigration: 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]) + } +} +``` + +## Phase 7: Additional Utility Files + +### 7.1 Device Information + +#### `Sources/LocalData/Utilities/DeviceInfo.swift` +```swift +#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 enum Platform: String, CaseIterable, Sendable { + case iOS = "iOS" + case watchOS = "watchOS" + case unknown = "unknown" +} +``` + +#### `Sources/LocalData/Utilities/SystemInfo.swift` +```swift +/// 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() { + // Get disk space + let systemAttributes = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) + self.availableDiskSpace = (systemAttributes[.systemFreeSize] as? UInt64) ?? 0 + + // Get memory + self.availableMemory = ProcessInfo.processInfo.physicalMemory + + // Get low power mode + self.isLowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled + } +} +``` + +### 7.2 Migration Utilities + +#### `Sources/LocalData/Utilities/MigrationUtils.swift` +```swift +/// Utilities for common migration operations +public enum MigrationUtils { + /// Check if data can be transformed from source to destination type + public static func canTransform(from: T.Type, to: U.Type) -> Bool { + // Check for common transformation patterns + return T.self is U.Type || T.self == String.self || U.self == String.self + } + + /// Get file size for migration considerations + public static func estimatedSize(for data: Data) -> UInt64 { + return UInt64(data.count) + } + + /// Validate migration compatibility + public static func validateCompatibility(source: StorageKeyDescriptor, destination: StorageKeyDescriptor) throws { + // Check if domains are compatible + // Check if types can be transformed + // Validate security policies + } +} +``` + +## Implementation Timeline + +| Phase | Duration | Description | +|--------|-----------|-------------| +| Phase 1: Core Protocol Infrastructure | 3-4 days | Create new protocols and models | +| Phase 2: Concrete Migration Implementations | 2-3 days | Implement all migration types | +| Phase 3: StorageRouter Integration | 2-3 days | Update router to use new protocol system | +| Phase 4: Sample App Updates | 2-3 days | Demonstrate new capabilities in sample app | +| Phase 5: Documentation Updates | 2 days | Comprehensive documentation overhaul | +| Phase 6: Testing Implementation | 3-4 days | Comprehensive test coverage | +| Phase 7: Additional Utilities | 1-2 days | Helper utilities and final polish | +| **Total** | **15-21 days** | **Complete migration refactor** | + +## Success Criteria + +1. ✅ **Protocol-Based Migration System**: All migration types implemented and functional +2. ✅ **Migration Registration**: Catalogs and explicit registration compose correctly +3. ✅ **Sample App Demonstration**: All new migration types shown in working examples +4. ✅ **Documentation**: Comprehensive documentation and examples +5. ✅ **Test Coverage**: All new functionality thoroughly tested +6. ✅ **Performance**: Minimal impact on non-migration operations +7. ✅ **Error Handling**: Robust error reporting and recovery +8. ✅ **Migration History**: Complete tracking and prevention of re-migration + +## Migration Checklist + +- [x] Create core protocol files +- [x] Update StorageKey protocol with migration +- [x] Create concrete migration implementations +- [x] Update StorageRouter integration with simple and advanced migration handling +- [x] Add WatchOS/sync defaults to StorageMigration +- [x] Add migration registration methods +- [x] Update sample app with new examples +- [x] Create new storage keys for demo +- [x] Implement comprehensive test suite including audits of existing tests, failure/rollback, watchOS scenarios +- [x] Add mock implementations for testing +- [x] Create utility files for device/system info +- [x] Add migration history tracking +- [x] Implement error handling and recovery +- [x] Verify migration registration across multiple catalogs +- [x] Performance testing with large datasets, including type erasure benchmarks +- [x] Overhaul Migration.md documentation +- [x] Update README.md with migration examples +- [x] Final documentation review + +## Notes + +### Removal Notes +- Remove legacy `migrationSources` usage from the codebase (no compatibility layer needed) +- Update documentation to reflect the new migration surface only + +### Performance Considerations +- Conditional checks are fast before expensive operations +- In-memory history tracking uses efficient lookup +- Async operations for non-blocking migrations +- Batch processing in AggregatingMigration for large data (e.g., streaming chunks) +- Benchmarks in Phase 6 for type erasure overhead (e.g., compare AnyStorageMigration vs. potential 'any' alternatives); evaluate non-erased generics if issues found +- Performance tests for >1MB migrations to ensure efficiency + +### Error Handling +- Comprehensive error types for all failure scenarios +- Migration result provides detailed information +- Rollback support for failed migrations +- Recovery options for partial failures + +### Testing Strategy +- Preserve existing test patterns and coverage +- Add new tests for all protocol types +- Mock implementations for isolated testing +- Integration tests for end-to-end workflows +- Performance tests for large data migrations + +This refactor provides a powerful, flexible migration system while maintaining the excellent code quality and comprehensive test coverage of the existing LocalData package. diff --git a/Proposal.md b/Proposal.md index 1332c05..eb515d9 100644 --- a/Proposal.md +++ b/Proposal.md @@ -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 key’s 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 diff --git a/README.md b/README.md index 520d292..5c83a1c 100644 --- a/README.md +++ b/README.md @@ -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 key’s 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 diff --git a/Sources/LocalData/Migrations/AppVersionConditionalMigration.swift b/Sources/LocalData/Migrations/AppVersionConditionalMigration.swift new file mode 100644 index 0000000..d434c1d --- /dev/null +++ b/Sources/LocalData/Migrations/AppVersionConditionalMigration.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Conditional migration for app version-based migration. +public struct AppVersionConditionalMigration: 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) + } +} diff --git a/Sources/LocalData/Migrations/DefaultAggregatingMigration.swift b/Sources/LocalData/Migrations/DefaultAggregatingMigration.swift new file mode 100644 index 0000000..90812f3 --- /dev/null +++ b/Sources/LocalData/Migrations/DefaultAggregatingMigration.swift @@ -0,0 +1,79 @@ +import Foundation + +public struct DefaultAggregatingMigration: 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 + ) + } + } +} diff --git a/Sources/LocalData/Migrations/DefaultTransformingMigration.swift b/Sources/LocalData/Migrations/DefaultTransformingMigration.swift new file mode 100644 index 0000000..b6c02d2 --- /dev/null +++ b/Sources/LocalData/Migrations/DefaultTransformingMigration.swift @@ -0,0 +1,56 @@ +import Foundation + +public struct DefaultTransformingMigration: 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 + ) + } + } +} diff --git a/Sources/LocalData/Migrations/SimpleLegacyMigration.swift b/Sources/LocalData/Migrations/SimpleLegacyMigration.swift new file mode 100644 index 0000000..332b8da --- /dev/null +++ b/Sources/LocalData/Migrations/SimpleLegacyMigration.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Simple 1:1 legacy migration. +public struct SimpleLegacyMigration: 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 + ) + } +} diff --git a/Sources/LocalData/Models/AnyStorageKey.swift b/Sources/LocalData/Models/AnyStorageKey.swift index f3f2de6..ee5904d 100644 --- a/Sources/LocalData/Models/AnyStorageKey.swift +++ b/Sources/LocalData/Models/AnyStorageKey.swift @@ -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: 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. diff --git a/Sources/LocalData/Models/AnyStorageMigration.swift b/Sources/LocalData/Models/AnyStorageMigration.swift new file mode 100644 index 0000000..c8f39f1 --- /dev/null +++ b/Sources/LocalData/Models/AnyStorageMigration.swift @@ -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(_ 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) + } +} diff --git a/Sources/LocalData/Models/MigrationContext.swift b/Sources/LocalData/Models/MigrationContext.swift new file mode 100644 index 0000000..78e775b --- /dev/null +++ b/Sources/LocalData/Models/MigrationContext.swift @@ -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 + } +} diff --git a/Sources/LocalData/Models/MigrationError.swift b/Sources/LocalData/Models/MigrationError.swift new file mode 100644 index 0000000..d44b46e --- /dev/null +++ b/Sources/LocalData/Models/MigrationError.swift @@ -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)" + } + } +} diff --git a/Sources/LocalData/Models/MigrationResult.swift b/Sources/LocalData/Models/MigrationResult.swift new file mode 100644 index 0000000..c98c39f --- /dev/null +++ b/Sources/LocalData/Models/MigrationResult.swift @@ -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 + } +} diff --git a/Sources/LocalData/Models/PlatformAvailability.swift b/Sources/LocalData/Models/PlatformAvailability.swift index 3ddcd55..ac198fa 100644 --- a/Sources/LocalData/Models/PlatformAvailability.swift +++ b/Sources/LocalData/Models/PlatformAvailability.swift @@ -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 + } + } +} diff --git a/Sources/LocalData/Models/StorageDomain.swift b/Sources/LocalData/Models/StorageDomain.swift index fab42d0..010439f 100644 --- a/Sources/LocalData/Models/StorageDomain.swift +++ b/Sources/LocalData/Models/StorageDomain.swift @@ -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?) diff --git a/Sources/LocalData/Protocols/AggregatingMigration.swift b/Sources/LocalData/Protocols/AggregatingMigration.swift new file mode 100644 index 0000000..704b8d4 --- /dev/null +++ b/Sources/LocalData/Protocols/AggregatingMigration.swift @@ -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 +} diff --git a/Sources/LocalData/Protocols/ConditionalMigration.swift b/Sources/LocalData/Protocols/ConditionalMigration.swift new file mode 100644 index 0000000..f0a35bc --- /dev/null +++ b/Sources/LocalData/Protocols/ConditionalMigration.swift @@ -0,0 +1,4 @@ +import Foundation + +/// Marker protocol for migrations that primarily use conditional checks. +public protocol ConditionalMigration: StorageMigration {} diff --git a/Sources/LocalData/Protocols/StorageKey.swift b/Sources/LocalData/Protocols/StorageKey.swift index 8ec430b..9002be8 100644 --- a/Sources/LocalData/Protocols/StorageKey.swift +++ b/Sources/LocalData/Protocols/StorageKey.swift @@ -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 } } diff --git a/Sources/LocalData/Protocols/StorageMigration.swift b/Sources/LocalData/Protocols/StorageMigration.swift new file mode 100644 index 0000000..4995a52 --- /dev/null +++ b/Sources/LocalData/Protocols/StorageMigration.swift @@ -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) + } +} diff --git a/Sources/LocalData/Protocols/TransformingMigration.swift b/Sources/LocalData/Protocols/TransformingMigration.swift new file mode 100644 index 0000000..df5db95 --- /dev/null +++ b/Sources/LocalData/Protocols/TransformingMigration.swift @@ -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 +} diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift index 6fc198f..d0cfc99 100644 --- a/Sources/LocalData/Services/StorageRouter.swift +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -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 @@ -126,6 +125,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(for key: Key) -> Date? { + migrationHistory[key.name] + } // MARK: - StorageProviding Implementation @@ -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(for key: Key) async throws { + /// - Returns: The migration result. + /// - Throws: Migration or storage errors. + public func forceMigration(for key: Key) async throws -> MigrationResult { 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) - } + try validateCatalogRegistration(for: key) + + 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 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. @@ -255,10 +240,32 @@ 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(for key: Key) throws { + nonisolated private func validatePlatformAvailability(for key: Key) throws { #if os(watchOS) if key.availability == .phoneOnly { throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name) @@ -321,6 +328,58 @@ public actor StorageRouter: StorageProviding { throw StorageError.missingDescription(missing.sorted().joined(separator: ", ")) } } + + // MARK: - Migration + + private func attemptMigration(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(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( + 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 @@ -335,7 +394,7 @@ public actor StorageRouter: StorageProviding { } } - private func deserialize( + nonisolated internal func deserialize( _ data: Data, with serializer: Serializer ) 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) diff --git a/Sources/LocalData/Utilities/DeviceInfo.swift b/Sources/LocalData/Utilities/DeviceInfo.swift new file mode 100644 index 0000000..886637a --- /dev/null +++ b/Sources/LocalData/Utilities/DeviceInfo.swift @@ -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 + } +} diff --git a/Sources/LocalData/Utilities/MigrationUtils.swift b/Sources/LocalData/Utilities/MigrationUtils.swift new file mode 100644 index 0000000..e7c312d --- /dev/null +++ b/Sources/LocalData/Utilities/MigrationUtils.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Utilities for common migration operations. +public enum MigrationUtils { + public static func canTransform(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)") + } + } +} diff --git a/Sources/LocalData/Utilities/Platform.swift b/Sources/LocalData/Utilities/Platform.swift new file mode 100644 index 0000000..cc3dfc4 --- /dev/null +++ b/Sources/LocalData/Utilities/Platform.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum Platform: String, CaseIterable, Sendable { + case iOS = "iOS" + case watchOS = "watchOS" + case unknown = "unknown" +} diff --git a/Sources/LocalData/Utilities/SystemInfo.swift b/Sources/LocalData/Utilities/SystemInfo.swift new file mode 100644 index 0000000..83afece --- /dev/null +++ b/Sources/LocalData/Utilities/SystemInfo.swift @@ -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 + } +} diff --git a/Tests/LocalDataTests/AnyStorageKeyTests.swift b/Tests/LocalDataTests/AnyStorageKeyTests.swift index 2706558..eeabf51 100644 --- a/Tests/LocalDataTests/AnyStorageKeyTests.swift +++ b/Tests/LocalDataTests/AnyStorageKeyTests.swift @@ -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) } } diff --git a/Tests/LocalDataTests/MigrationAdditionalTests.swift b/Tests/LocalDataTests/MigrationAdditionalTests.swift new file mode 100644 index 0000000..532f8f8 --- /dev/null +++ b/Tests/LocalDataTests/MigrationAdditionalTests.swift @@ -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 = .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 = .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 = .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 = .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 = .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) + } +} diff --git a/Tests/LocalDataTests/MigrationIntegrationTests.swift b/Tests/LocalDataTests/MigrationIntegrationTests.swift new file mode 100644 index 0000000..85593e0 --- /dev/null +++ b/Tests/LocalDataTests/MigrationIntegrationTests.swift @@ -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 = .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 = .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")) + } +} diff --git a/Tests/LocalDataTests/MigrationProtocolTests.swift b/Tests/LocalDataTests/MigrationProtocolTests.swift new file mode 100644 index 0000000..1b446da --- /dev/null +++ b/Tests/LocalDataTests/MigrationProtocolTests.swift @@ -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 = .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 = .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 = .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 = .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")) + } +} diff --git a/Tests/LocalDataTests/MigrationTests.swift b/Tests/LocalDataTests/MigrationTests.swift index 6fecbc5..b2d0e79 100644 --- a/Tests/LocalDataTests/MigrationTests.swift +++ b/Tests/LocalDataTests/MigrationTests.swift @@ -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) diff --git a/Tests/LocalDataTests/Mocks/MockMigration.swift b/Tests/LocalDataTests/Mocks/MockMigration.swift new file mode 100644 index 0000000..26de09e --- /dev/null +++ b/Tests/LocalDataTests/Mocks/MockMigration.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import LocalData + +struct MockMigration: 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: 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]) + } +}