Update Migrations, Models, Protocols (+2 more) + tests + docs
Summary: - Sources: Migrations, Models, Protocols, Services, Utilities - Tests: AnyStorageKeyTests.swift, MigrationAdditionalTests.swift, MigrationIntegrationTests.swift, MigrationProtocolTests.swift, MigrationTests.swift (+1 more) - Docs: Migration, Migration_Refactor_Plan_Clean, Proposal, README - Added symbols: struct MyMigration, typealias DestinationKey, func shouldMigrate, func migrate, extension MyNewKey, protocol StorageMigration (+74 more) - Removed symbols: enum StorageDomain, func migrate, func validatePlatformAvailability, func deserialize, func applySecurity, func retrieve (+1 more) Stats: - 31 files changed, 2820 insertions(+), 80 deletions(-)
This commit is contained in:
parent
35ce409f4e
commit
c2b17a4b7f
@ -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())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
1595
Documentation/Migration_Refactor_Plan_Clean.md
Normal file
1595
Documentation/Migration_Refactor_Plan_Clean.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
|
||||
69
README.md
69
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
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// Conditional migration for app version-based migration.
|
||||
public struct AppVersionConditionalMigration<Destination: StorageKey>: ConditionalMigration {
|
||||
public typealias DestinationKey = Destination
|
||||
|
||||
public let destinationKey: Destination
|
||||
public let minAppVersion: String
|
||||
public let fallbackMigration: AnyStorageMigration
|
||||
|
||||
public init(
|
||||
destinationKey: Destination,
|
||||
minAppVersion: String,
|
||||
fallbackMigration: AnyStorageMigration
|
||||
) {
|
||||
self.destinationKey = destinationKey
|
||||
self.minAppVersion = minAppVersion
|
||||
self.fallbackMigration = fallbackMigration
|
||||
}
|
||||
|
||||
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||
let isEligible = context.appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending
|
||||
guard isEligible else { return false }
|
||||
return try await router.shouldAllowMigration(for: destinationKey, context: context)
|
||||
}
|
||||
|
||||
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||||
try await fallbackMigration.migrate(using: router, context: context)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
|
||||
public struct DefaultAggregatingMigration<Destination: StorageKey>: AggregatingMigration {
|
||||
public typealias DestinationKey = Destination
|
||||
|
||||
public let destinationKey: Destination
|
||||
public let sourceKeys: [AnyStorageKey]
|
||||
public let aggregateAction: ([AnyCodable]) async throws -> Destination.Value
|
||||
|
||||
public init(
|
||||
destinationKey: Destination,
|
||||
sourceKeys: [AnyStorageKey],
|
||||
aggregate: @escaping ([AnyCodable]) async throws -> Destination.Value
|
||||
) {
|
||||
self.destinationKey = destinationKey
|
||||
self.sourceKeys = sourceKeys
|
||||
self.aggregateAction = aggregate
|
||||
}
|
||||
|
||||
public func aggregate(_ sources: [AnyCodable]) async throws -> Destination.Value {
|
||||
try await aggregateAction(sources)
|
||||
}
|
||||
|
||||
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
|
||||
return false
|
||||
}
|
||||
if try await router.exists(destinationKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
for source in sourceKeys {
|
||||
if try await router.exists(descriptor: source.descriptor) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||||
let startTime = Date()
|
||||
var sourceData: [AnyCodable] = []
|
||||
var migratedCount = 0
|
||||
|
||||
do {
|
||||
for source in sourceKeys {
|
||||
if let data = try await router.retrieve(for: source.descriptor) {
|
||||
let unsecuredData = try await router.applySecurity(
|
||||
data,
|
||||
for: source.descriptor,
|
||||
isEncrypt: false
|
||||
)
|
||||
if let codable = try? JSONDecoder().decode(AnyCodable.self, from: unsecuredData) {
|
||||
sourceData.append(codable)
|
||||
migratedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let aggregatedData = try await aggregate(sourceData)
|
||||
try await router.set(aggregatedData, for: destinationKey)
|
||||
|
||||
for source in sourceKeys {
|
||||
try? await router.delete(for: source.descriptor)
|
||||
}
|
||||
|
||||
let duration = Date().timeIntervalSince(startTime)
|
||||
return MigrationResult(success: true, migratedCount: migratedCount, duration: duration)
|
||||
} catch {
|
||||
let duration = Date().timeIntervalSince(startTime)
|
||||
return MigrationResult(
|
||||
success: false,
|
||||
errors: [.aggregationFailed(error.localizedDescription)],
|
||||
duration: duration
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
public struct DefaultTransformingMigration<Source: StorageKey, Destination: StorageKey>: TransformingMigration {
|
||||
public typealias SourceKey = Source
|
||||
public typealias DestinationKey = Destination
|
||||
|
||||
public let destinationKey: Destination
|
||||
public let sourceKey: Source
|
||||
public let transformAction: (Source.Value) async throws -> Destination.Value
|
||||
|
||||
public init(
|
||||
destinationKey: Destination,
|
||||
sourceKey: Source,
|
||||
transform: @escaping (Source.Value) async throws -> Destination.Value
|
||||
) {
|
||||
self.destinationKey = destinationKey
|
||||
self.sourceKey = sourceKey
|
||||
self.transformAction = transform
|
||||
}
|
||||
|
||||
public func transform(_ source: Source.Value) async throws -> Destination.Value {
|
||||
try await transformAction(source)
|
||||
}
|
||||
|
||||
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
|
||||
return false
|
||||
}
|
||||
if try await router.exists(destinationKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
return try await router.exists(sourceKey)
|
||||
}
|
||||
|
||||
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||||
let startTime = Date()
|
||||
|
||||
do {
|
||||
let sourceData = try await router.get(sourceKey)
|
||||
let transformedData = try await transform(sourceData)
|
||||
try await router.set(transformedData, for: destinationKey)
|
||||
try await router.remove(sourceKey)
|
||||
|
||||
let duration = Date().timeIntervalSince(startTime)
|
||||
return MigrationResult(success: true, migratedCount: 1, duration: duration)
|
||||
} catch {
|
||||
let duration = Date().timeIntervalSince(startTime)
|
||||
return MigrationResult(
|
||||
success: false,
|
||||
errors: [.transformationFailed(error.localizedDescription)],
|
||||
duration: duration
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
62
Sources/LocalData/Migrations/SimpleLegacyMigration.swift
Normal file
62
Sources/LocalData/Migrations/SimpleLegacyMigration.swift
Normal file
@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
/// Simple 1:1 legacy migration.
|
||||
public struct SimpleLegacyMigration<Destination: StorageKey>: StorageMigration {
|
||||
public typealias DestinationKey = Destination
|
||||
|
||||
public let destinationKey: Destination
|
||||
public let sourceKey: AnyStorageKey
|
||||
|
||||
public init(destinationKey: Destination, sourceKey: AnyStorageKey) {
|
||||
self.destinationKey = destinationKey
|
||||
self.sourceKey = sourceKey
|
||||
}
|
||||
|
||||
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||
guard try await router.shouldAllowMigration(for: destinationKey, context: context) else {
|
||||
return false
|
||||
}
|
||||
if try await router.exists(destinationKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
return try await router.exists(descriptor: sourceKey.descriptor)
|
||||
}
|
||||
|
||||
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||||
let startTime = Date()
|
||||
var errors: [MigrationError] = []
|
||||
var migratedCount = 0
|
||||
|
||||
do {
|
||||
guard let sourceData = try await router.retrieve(for: sourceKey.descriptor) else {
|
||||
return MigrationResult(success: false, errors: [.sourceDataNotFound])
|
||||
}
|
||||
|
||||
let unsecuredData = try await router.applySecurity(
|
||||
sourceData,
|
||||
for: sourceKey.descriptor,
|
||||
isEncrypt: false
|
||||
)
|
||||
let value = try router.deserialize(unsecuredData, with: destinationKey.serializer)
|
||||
|
||||
try await router.set(value, for: destinationKey)
|
||||
try await router.delete(for: sourceKey.descriptor)
|
||||
|
||||
migratedCount = 1
|
||||
Logger.info("!!! [MIGRATION] Successfully migrated from '\(sourceKey.descriptor.name)' to '\(destinationKey.name)'")
|
||||
} catch {
|
||||
let storageError = error as? StorageError ?? .fileError(error.localizedDescription)
|
||||
errors.append(.storageFailed(storageError))
|
||||
Logger.error("!!! [MIGRATION] Failed to migrate from '\(sourceKey.descriptor.name)': \(error)")
|
||||
}
|
||||
|
||||
let duration = Date().timeIntervalSince(startTime)
|
||||
return MigrationResult(
|
||||
success: errors.isEmpty,
|
||||
migratedCount: migratedCount,
|
||||
errors: errors,
|
||||
duration: duration
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,23 @@
|
||||
public struct AnyStorageKey: Sendable {
|
||||
public internal(set) var descriptor: StorageKeyDescriptor
|
||||
public internal(set) var migration: AnyStorageMigration?
|
||||
private let migrateAction: @Sendable (StorageRouter) async throws -> Void
|
||||
|
||||
public init<Key: StorageKey>(_ key: Key) {
|
||||
self.descriptor = .from(key)
|
||||
self.migration = key.migration
|
||||
self.migrateAction = { router in
|
||||
try await router.migrate(for: key)
|
||||
_ = try await router.forceMigration(for: key)
|
||||
}
|
||||
}
|
||||
|
||||
private init(descriptor: StorageKeyDescriptor, migrateAction: @escaping @Sendable (StorageRouter) async throws -> Void) {
|
||||
private init(
|
||||
descriptor: StorageKeyDescriptor,
|
||||
migration: AnyStorageMigration?,
|
||||
migrateAction: @escaping @Sendable (StorageRouter) async throws -> Void
|
||||
) {
|
||||
self.descriptor = descriptor
|
||||
self.migration = migration
|
||||
self.migrateAction = migrateAction
|
||||
}
|
||||
|
||||
@ -20,7 +27,11 @@ public struct AnyStorageKey: Sendable {
|
||||
|
||||
/// Internal use: Returns a copy of this key with the catalog name set.
|
||||
internal func withCatalog(_ name: String) -> AnyStorageKey {
|
||||
AnyStorageKey(descriptor: descriptor.withCatalog(name), migrateAction: migrateAction)
|
||||
AnyStorageKey(
|
||||
descriptor: descriptor.withCatalog(name),
|
||||
migration: migration,
|
||||
migrateAction: migrateAction
|
||||
)
|
||||
}
|
||||
|
||||
/// Internal use: Triggers the migration logic for this key.
|
||||
|
||||
23
Sources/LocalData/Models/AnyStorageMigration.swift
Normal file
23
Sources/LocalData/Models/AnyStorageMigration.swift
Normal file
@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
/// Type-erased wrapper for StorageMigration to match AnyStorageKey patterns.
|
||||
public struct AnyStorageMigration: Sendable {
|
||||
public let destinationDescriptor: StorageKeyDescriptor
|
||||
|
||||
private let shouldMigrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> Bool
|
||||
private let migrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> MigrationResult
|
||||
|
||||
public init<M: StorageMigration>(_ migration: M) {
|
||||
self.destinationDescriptor = .from(migration.destinationKey)
|
||||
self.shouldMigrateAction = migration.shouldMigrate
|
||||
self.migrateAction = migration.migrate
|
||||
}
|
||||
|
||||
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||
try await shouldMigrateAction(router, context)
|
||||
}
|
||||
|
||||
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||||
try await migrateAction(router, context)
|
||||
}
|
||||
}
|
||||
24
Sources/LocalData/Models/MigrationContext.swift
Normal file
24
Sources/LocalData/Models/MigrationContext.swift
Normal file
@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
/// Context information available for conditional migrations.
|
||||
public struct MigrationContext: Sendable {
|
||||
public let appVersion: String
|
||||
public let deviceInfo: DeviceInfo
|
||||
public let migrationHistory: [String: Date]
|
||||
public let userPreferences: [String: AnyCodable]
|
||||
public let systemInfo: SystemInfo
|
||||
|
||||
public init(
|
||||
appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
|
||||
deviceInfo: DeviceInfo = .current,
|
||||
migrationHistory: [String: Date] = [:],
|
||||
userPreferences: [String: AnyCodable] = [:],
|
||||
systemInfo: SystemInfo = .current
|
||||
) {
|
||||
self.appVersion = appVersion
|
||||
self.deviceInfo = deviceInfo
|
||||
self.migrationHistory = migrationHistory
|
||||
self.userPreferences = userPreferences
|
||||
self.systemInfo = systemInfo
|
||||
}
|
||||
}
|
||||
36
Sources/LocalData/Models/MigrationError.swift
Normal file
36
Sources/LocalData/Models/MigrationError.swift
Normal file
@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
/// Migration-specific error types.
|
||||
public enum MigrationError: Error, Sendable, Equatable {
|
||||
case validationFailed(String)
|
||||
case transformationFailed(String)
|
||||
case storageFailed(StorageError)
|
||||
case conditionalMigrationFailed
|
||||
case migrationInProgress
|
||||
case sourceDataNotFound
|
||||
case incompatibleTypes(String)
|
||||
case aggregationFailed(String)
|
||||
}
|
||||
|
||||
extension MigrationError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .validationFailed(let message):
|
||||
return "Migration validation failed: \(message)"
|
||||
case .transformationFailed(let message):
|
||||
return "Data transformation failed: \(message)"
|
||||
case .storageFailed(let storageError):
|
||||
return "Storage operation failed: \(storageError.localizedDescription)"
|
||||
case .conditionalMigrationFailed:
|
||||
return "Conditional migration criteria not met"
|
||||
case .migrationInProgress:
|
||||
return "Migration already in progress"
|
||||
case .sourceDataNotFound:
|
||||
return "No data found in migration source"
|
||||
case .incompatibleTypes(let message):
|
||||
return "Type incompatibility: \(message)"
|
||||
case .aggregationFailed(let message):
|
||||
return "Data aggregation failed: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
Sources/LocalData/Models/MigrationResult.swift
Normal file
24
Sources/LocalData/Models/MigrationResult.swift
Normal file
@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
/// Result of a migration operation with detailed information.
|
||||
public struct MigrationResult: Sendable {
|
||||
public let success: Bool
|
||||
public let migratedCount: Int
|
||||
public let errors: [MigrationError]
|
||||
public let metadata: [String: AnyCodable]
|
||||
public let duration: TimeInterval
|
||||
|
||||
public init(
|
||||
success: Bool,
|
||||
migratedCount: Int = 0,
|
||||
errors: [MigrationError] = [],
|
||||
metadata: [String: AnyCodable] = [:],
|
||||
duration: TimeInterval = 0
|
||||
) {
|
||||
self.success = success
|
||||
self.migratedCount = migratedCount
|
||||
self.errors = errors
|
||||
self.metadata = metadata
|
||||
self.duration = duration
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?)
|
||||
|
||||
7
Sources/LocalData/Protocols/AggregatingMigration.swift
Normal file
7
Sources/LocalData/Protocols/AggregatingMigration.swift
Normal file
@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// Migration protocol that combines multiple sources into a single destination.
|
||||
public protocol AggregatingMigration: StorageMigration {
|
||||
var sourceKeys: [AnyStorageKey] { get }
|
||||
func aggregate(_ sources: [AnyCodable]) async throws -> DestinationKey.Value
|
||||
}
|
||||
4
Sources/LocalData/Protocols/ConditionalMigration.swift
Normal file
4
Sources/LocalData/Protocols/ConditionalMigration.swift
Normal file
@ -0,0 +1,4 @@
|
||||
import Foundation
|
||||
|
||||
/// Marker protocol for migrations that primarily use conditional checks.
|
||||
public protocol ConditionalMigration: StorageMigration {}
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
21
Sources/LocalData/Protocols/StorageMigration.swift
Normal file
21
Sources/LocalData/Protocols/StorageMigration.swift
Normal file
@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// Core migration protocol with high-level methods.
|
||||
public protocol StorageMigration: Sendable {
|
||||
associatedtype DestinationKey: StorageKey
|
||||
|
||||
/// The destination storage key where migrated data will be stored.
|
||||
var destinationKey: DestinationKey { get }
|
||||
|
||||
/// Validate if migration should proceed (conditional logic).
|
||||
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool
|
||||
|
||||
/// Execute the migration process.
|
||||
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult
|
||||
}
|
||||
|
||||
public extension StorageMigration {
|
||||
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||
try await router.shouldAllowMigration(for: destinationKey, context: context)
|
||||
}
|
||||
}
|
||||
9
Sources/LocalData/Protocols/TransformingMigration.swift
Normal file
9
Sources/LocalData/Protocols/TransformingMigration.swift
Normal file
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
/// Migration protocol that supports data transformation during migration.
|
||||
public protocol TransformingMigration: StorageMigration {
|
||||
associatedtype SourceKey: StorageKey
|
||||
|
||||
var sourceKey: SourceKey { get }
|
||||
func transform(_ source: SourceKey.Value) async throws -> DestinationKey.Value
|
||||
}
|
||||
@ -1,7 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
import WatchConnectivity
|
||||
|
||||
/// The main storage router that coordinates all storage operations.
|
||||
/// Uses specialized helper actors for each storage domain.
|
||||
public actor StorageRouter: StorageProviding {
|
||||
@ -10,6 +8,7 @@ public actor StorageRouter: StorageProviding {
|
||||
|
||||
private var catalogRegistries: [String: [AnyStorageKey]] = [:]
|
||||
private var registeredKeys: [String: AnyStorageKey] = [:]
|
||||
private var migrationHistory: [String: Date] = [:]
|
||||
private var storageConfiguration: StorageConfiguration = .default
|
||||
private let keychain: KeychainStoring
|
||||
private let encryption: EncryptionHelper
|
||||
@ -127,6 +126,11 @@ public actor StorageRouter: StorageProviding {
|
||||
Logger.debug("<<< [STORAGE] GLOBAL MIGRATION SWEEP COMPLETE")
|
||||
}
|
||||
|
||||
/// Returns the last migration date for a specific key, if available.
|
||||
public func migrationHistory<Key: StorageKey>(for key: Key) -> Date? {
|
||||
migrationHistory[key.name]
|
||||
}
|
||||
|
||||
// MARK: - StorageProviding Implementation
|
||||
|
||||
/// Stores a value for the given key.
|
||||
@ -164,59 +168,40 @@ public actor StorageRouter: StorageProviding {
|
||||
return result
|
||||
}
|
||||
|
||||
// 2. Try migration sources
|
||||
for source in key.migrationSources {
|
||||
let descriptor = source.descriptor
|
||||
Logger.debug("!!! [STORAGE] MIGRATION: Checking source '\(descriptor.name)' for key '\(key.name)'")
|
||||
|
||||
if let securedOldData = try await retrieve(for: descriptor) {
|
||||
Logger.info("!!! [STORAGE] MIGRATION: Found data for '\(key.name)' at legacy source '\(descriptor.name)'. Migrating...")
|
||||
|
||||
// Unsecure using OLD key's policy
|
||||
let oldData = try await applySecurity(securedOldData, for: descriptor, isEncrypt: false)
|
||||
|
||||
// Decode using NEW key's serializer (assuming types are compatible)
|
||||
let value = try deserialize(oldData, with: key.serializer)
|
||||
|
||||
// Store in NEW location with NEW security
|
||||
// This will also handle sync and catalog validation via recursive call to public set
|
||||
try await self.set(value, for: key)
|
||||
|
||||
// Delete OLD data
|
||||
try await delete(for: descriptor)
|
||||
|
||||
Logger.info("!!! [STORAGE] MIGRATION: Successfully migrated '\(key.name)' from '\(descriptor.name)'")
|
||||
return value
|
||||
}
|
||||
// 2. Attempt migration (legacy or advanced)
|
||||
if let migratedValue = try await attemptMigration(for: key) {
|
||||
return migratedValue
|
||||
}
|
||||
|
||||
Logger.debug("<<< [STORAGE] GET NOT FOUND: \(key.name)")
|
||||
throw StorageError.notFound
|
||||
}
|
||||
|
||||
/// Manually triggers migration from legacy sources for a specific key.
|
||||
/// Manually triggers migration for a specific key.
|
||||
/// This is useful for "draining" old keys at app startup even if the destination already has data.
|
||||
/// - Parameter key: The storage key to migrate.
|
||||
/// - Throws: Various storage and serialization errors.
|
||||
public func migrate<Key: StorageKey>(for key: Key) async throws {
|
||||
/// - Returns: The migration result.
|
||||
/// - Throws: Migration or storage errors.
|
||||
public func forceMigration<Key: StorageKey>(for key: Key) async throws -> MigrationResult {
|
||||
Logger.debug(">>> [STORAGE] MANUAL MIGRATION: \(key.name)")
|
||||
try validateCatalogRegistration(for: key)
|
||||
|
||||
for source in key.migrationSources {
|
||||
let descriptor = source.descriptor
|
||||
if let securedOldData = try await retrieve(for: descriptor) {
|
||||
Logger.info("!!! [STORAGE] MANUAL MIGRATION: Migrating found data from '\(descriptor.name)' to '\(key.name)'")
|
||||
guard let migration = resolveMigration(for: key) else {
|
||||
Logger.debug("<<< [STORAGE] MANUAL MIGRATION: No migration configured for \(key.name)")
|
||||
return MigrationResult(success: true, migratedCount: 0)
|
||||
}
|
||||
|
||||
let oldData = try await applySecurity(securedOldData, for: descriptor, isEncrypt: false)
|
||||
let value = try deserialize(oldData, with: key.serializer)
|
||||
|
||||
// Store in NEW location
|
||||
try await self.set(value, for: key)
|
||||
|
||||
// Delete OLD data
|
||||
try await delete(for: descriptor)
|
||||
}
|
||||
let context = buildMigrationContext()
|
||||
guard try await shouldAllowMigration(for: key, context: context) else {
|
||||
Logger.debug("<<< [STORAGE] MANUAL MIGRATION: Skipped for \(key.name) on this platform")
|
||||
return MigrationResult(success: true, migratedCount: 0)
|
||||
}
|
||||
let result = try await migration.migrate(using: self, context: context)
|
||||
if result.success {
|
||||
recordMigration(for: .from(key))
|
||||
}
|
||||
Logger.debug("<<< [STORAGE] MANUAL MIGRATION COMPLETE: \(key.name)")
|
||||
return result
|
||||
}
|
||||
|
||||
/// Removes the value for the given key.
|
||||
@ -256,9 +241,31 @@ public actor StorageRouter: StorageProviding {
|
||||
}
|
||||
}
|
||||
|
||||
internal func exists(descriptor: StorageKeyDescriptor) async throws -> Bool {
|
||||
switch descriptor.domain {
|
||||
case .userDefaults(let suite):
|
||||
return try await defaults.exists(forKey: descriptor.name, suite: suite)
|
||||
case .appGroupUserDefaults(let identifier):
|
||||
let resolvedId = try resolveIdentifier(identifier)
|
||||
return try await defaults.exists(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
||||
case .keychain(let service):
|
||||
let resolvedService = try resolveService(service)
|
||||
return try await keychain.exists(service: resolvedService, key: descriptor.name)
|
||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||
return await file.exists(in: directory, fileName: descriptor.name)
|
||||
case .appGroupFileSystem(let identifier, let directory):
|
||||
let resolvedId = try resolveIdentifier(identifier)
|
||||
return await file.exists(
|
||||
in: directory,
|
||||
fileName: descriptor.name,
|
||||
appGroupIdentifier: resolvedId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Platform Validation
|
||||
|
||||
private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws {
|
||||
nonisolated private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws {
|
||||
#if os(watchOS)
|
||||
if key.availability == .phoneOnly {
|
||||
throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name)
|
||||
@ -322,6 +329,58 @@ public actor StorageRouter: StorageProviding {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Migration
|
||||
|
||||
private func attemptMigration<Key: StorageKey>(for key: Key) async throws -> Key.Value? {
|
||||
guard let migration = resolveMigration(for: key) else { return nil }
|
||||
|
||||
let context = buildMigrationContext()
|
||||
let shouldMigrate = try await migration.shouldMigrate(using: self, context: context)
|
||||
guard shouldMigrate else { return nil }
|
||||
|
||||
let result = try await migration.migrate(using: self, context: context)
|
||||
if result.success {
|
||||
recordMigration(for: .from(key))
|
||||
if let securedData = try await retrieve(for: .from(key)) {
|
||||
let data = try await applySecurity(securedData, for: .from(key), isEncrypt: false)
|
||||
return try deserialize(data, with: key.serializer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let error = result.errors.first {
|
||||
throw error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveMigration<Key: StorageKey>(for key: Key) -> AnyStorageMigration? {
|
||||
key.migration
|
||||
}
|
||||
|
||||
internal func buildMigrationContext() -> MigrationContext {
|
||||
MigrationContext(migrationHistory: migrationHistory)
|
||||
}
|
||||
|
||||
internal func recordMigration(for descriptor: StorageKeyDescriptor) {
|
||||
migrationHistory[descriptor.name] = Date()
|
||||
}
|
||||
|
||||
internal func shouldAllowMigration<Key: StorageKey>(
|
||||
for key: Key,
|
||||
context: MigrationContext
|
||||
) async throws -> Bool {
|
||||
guard key.availability.isAvailable(on: context.deviceInfo.platform) else {
|
||||
return false
|
||||
}
|
||||
|
||||
if key.syncPolicy != .never {
|
||||
let isSyncAvailable = await sync.isSyncAvailable()
|
||||
guard isSyncAvailable else { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
private func serialize<Value: Codable & Sendable>(
|
||||
@ -335,7 +394,7 @@ public actor StorageRouter: StorageProviding {
|
||||
}
|
||||
}
|
||||
|
||||
private func deserialize<Value: Codable & Sendable>(
|
||||
nonisolated internal func deserialize<Value: Codable & Sendable>(
|
||||
_ data: Data,
|
||||
with serializer: Serializer<Value>
|
||||
) throws -> Value {
|
||||
@ -348,7 +407,7 @@ public actor StorageRouter: StorageProviding {
|
||||
|
||||
// MARK: - Security
|
||||
|
||||
private func applySecurity(
|
||||
internal func applySecurity(
|
||||
_ data: Data,
|
||||
for descriptor: StorageKeyDescriptor,
|
||||
isEncrypt: Bool
|
||||
@ -435,7 +494,7 @@ public actor StorageRouter: StorageProviding {
|
||||
}
|
||||
}
|
||||
|
||||
private func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
|
||||
internal func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
|
||||
switch descriptor.domain {
|
||||
case .userDefaults(let suite):
|
||||
return try await defaults.get(forKey: descriptor.name, suite: suite)
|
||||
@ -459,7 +518,7 @@ public actor StorageRouter: StorageProviding {
|
||||
}
|
||||
}
|
||||
|
||||
private func delete(for descriptor: StorageKeyDescriptor) async throws {
|
||||
internal func delete(for descriptor: StorageKeyDescriptor) async throws {
|
||||
switch descriptor.domain {
|
||||
case .userDefaults(let suite):
|
||||
try await defaults.remove(forKey: descriptor.name, suite: suite)
|
||||
|
||||
43
Sources/LocalData/Utilities/DeviceInfo.swift
Normal file
43
Sources/LocalData/Utilities/DeviceInfo.swift
Normal file
@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
#if os(watchOS)
|
||||
import WatchKit
|
||||
#endif
|
||||
|
||||
/// Device information for migration context.
|
||||
public struct DeviceInfo: Sendable {
|
||||
public let platform: Platform
|
||||
public let systemVersion: String
|
||||
public let model: String
|
||||
public let isSimulator: Bool
|
||||
|
||||
public static let current = DeviceInfo()
|
||||
|
||||
private init() {
|
||||
#if os(iOS)
|
||||
self.platform = .iOS
|
||||
self.systemVersion = UIDevice.current.systemVersion
|
||||
self.model = UIDevice.current.model
|
||||
#elseif os(watchOS)
|
||||
self.platform = .watchOS
|
||||
self.systemVersion = WKInterfaceDevice.current().systemVersion
|
||||
self.model = WKInterfaceDevice.current().model
|
||||
#else
|
||||
self.platform = .unknown
|
||||
self.systemVersion = ProcessInfo.processInfo.operatingSystemVersionString
|
||||
self.model = "Unknown"
|
||||
#endif
|
||||
|
||||
self.isSimulator = ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil
|
||||
}
|
||||
|
||||
public init(platform: Platform, systemVersion: String, model: String, isSimulator: Bool) {
|
||||
self.platform = platform
|
||||
self.systemVersion = systemVersion
|
||||
self.model = model
|
||||
self.isSimulator = isSimulator
|
||||
}
|
||||
}
|
||||
26
Sources/LocalData/Utilities/MigrationUtils.swift
Normal file
26
Sources/LocalData/Utilities/MigrationUtils.swift
Normal file
@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// Utilities for common migration operations.
|
||||
public enum MigrationUtils {
|
||||
public static func canTransform<T, U>(from: T.Type, to: U.Type) -> Bool {
|
||||
if T.self is U.Type { return true }
|
||||
return T.self == String.self || U.self == String.self
|
||||
}
|
||||
|
||||
public static func estimatedSize(for data: Data) -> UInt64 {
|
||||
UInt64(data.count)
|
||||
}
|
||||
|
||||
public static func validateCompatibility(
|
||||
source: StorageKeyDescriptor,
|
||||
destination: StorageKeyDescriptor
|
||||
) throws {
|
||||
if source.name == destination.name && source.domain == destination.domain {
|
||||
throw MigrationError.validationFailed("Source and destination are identical")
|
||||
}
|
||||
|
||||
if source.valueType != destination.valueType {
|
||||
throw MigrationError.incompatibleTypes("\(source.valueType) -> \(destination.valueType)")
|
||||
}
|
||||
}
|
||||
}
|
||||
7
Sources/LocalData/Utilities/Platform.swift
Normal file
7
Sources/LocalData/Utilities/Platform.swift
Normal file
@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public enum Platform: String, CaseIterable, Sendable {
|
||||
case iOS = "iOS"
|
||||
case watchOS = "watchOS"
|
||||
case unknown = "unknown"
|
||||
}
|
||||
17
Sources/LocalData/Utilities/SystemInfo.swift
Normal file
17
Sources/LocalData/Utilities/SystemInfo.swift
Normal file
@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
/// System information for migration context.
|
||||
public struct SystemInfo: Sendable {
|
||||
public let availableDiskSpace: UInt64
|
||||
public let availableMemory: UInt64
|
||||
public let isLowPowerModeEnabled: Bool
|
||||
|
||||
public static let current = SystemInfo()
|
||||
|
||||
private init() {
|
||||
let systemAttributes = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
|
||||
self.availableDiskSpace = (systemAttributes?[.systemFreeSize] as? UInt64) ?? 0
|
||||
self.availableMemory = ProcessInfo.processInfo.physicalMemory
|
||||
self.isLowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
176
Tests/LocalDataTests/MigrationAdditionalTests.swift
Normal file
176
Tests/LocalDataTests/MigrationAdditionalTests.swift
Normal file
@ -0,0 +1,176 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LocalData
|
||||
|
||||
private struct LegacyStringKey: StorageKey {
|
||||
typealias Value = String
|
||||
let name: String
|
||||
let domain: StorageDomain
|
||||
let security: SecurityPolicy = .none
|
||||
let serializer: Serializer<String> = .json
|
||||
let owner: String = "Legacy"
|
||||
let description: String = "Legacy string key"
|
||||
let availability: PlatformAvailability = .all
|
||||
let syncPolicy: SyncPolicy = .never
|
||||
}
|
||||
|
||||
private struct ModernStringKey: StorageKey {
|
||||
typealias Value = String
|
||||
let name: String
|
||||
let domain: StorageDomain
|
||||
let security: SecurityPolicy = .none
|
||||
let serializer: Serializer<String> = .json
|
||||
let owner: String = "Modern"
|
||||
let description: String = "Modern string key"
|
||||
let availability: PlatformAvailability = .all
|
||||
let syncPolicy: SyncPolicy = .never
|
||||
let legacyKey: AnyStorageKey?
|
||||
|
||||
var migration: AnyStorageMigration? {
|
||||
guard let legacyKey else { return nil }
|
||||
return AnyStorageMigration(
|
||||
SimpleLegacyMigration(destinationKey: self, sourceKey: legacyKey)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PhoneOnlyKey: StorageKey {
|
||||
typealias Value = String
|
||||
let name: String
|
||||
let domain: StorageDomain
|
||||
let security: SecurityPolicy = .none
|
||||
let serializer: Serializer<String> = .json
|
||||
let owner: String = "PhoneOnly"
|
||||
let description: String = "Phone-only key"
|
||||
let availability: PlatformAvailability = .phoneOnly
|
||||
let syncPolicy: SyncPolicy = .never
|
||||
}
|
||||
|
||||
private struct SourceStringKey: StorageKey {
|
||||
typealias Value = String
|
||||
let name: String
|
||||
let domain: StorageDomain
|
||||
let security: SecurityPolicy = .none
|
||||
let serializer: Serializer<String> = .json
|
||||
let owner: String = "Source"
|
||||
let description: String = "Source key"
|
||||
let availability: PlatformAvailability = .all
|
||||
let syncPolicy: SyncPolicy = .never
|
||||
}
|
||||
|
||||
private struct DestinationIntKey: StorageKey {
|
||||
typealias Value = Int
|
||||
let name: String
|
||||
let domain: StorageDomain
|
||||
let security: SecurityPolicy = .none
|
||||
let serializer: Serializer<Int> = .json
|
||||
let owner: String = "Destination"
|
||||
let description: String = "Destination int key"
|
||||
let availability: PlatformAvailability = .all
|
||||
let syncPolicy: SyncPolicy = .never
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct MigrationAdditionalTests {
|
||||
private let router: StorageRouter
|
||||
|
||||
init() {
|
||||
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "MigrationAdditionalTests-\(UUID().uuidString)")
|
||||
router = StorageRouter(
|
||||
keychain: MockKeychainHelper(),
|
||||
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
|
||||
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
|
||||
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "MigrationAdditionalTests.\(UUID().uuidString)")!)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func migrationHistoryTrackingTest() async throws {
|
||||
let legacyKey = LegacyStringKey(name: "legacy.history", domain: .userDefaults(suite: nil))
|
||||
let modernKey = ModernStringKey(
|
||||
name: "modern.history",
|
||||
domain: .userDefaults(suite: nil),
|
||||
legacyKey: .key(legacyKey)
|
||||
)
|
||||
|
||||
try await router.set("history", for: legacyKey)
|
||||
_ = try await router.forceMigration(for: modernKey)
|
||||
|
||||
let history = router.migrationHistory(for: modernKey)
|
||||
#expect(history != nil)
|
||||
}
|
||||
|
||||
@Test func migrationFailureKeepsSourceTest() async throws {
|
||||
let sourceKey = SourceStringKey(name: "legacy.rollback", domain: .userDefaults(suite: nil))
|
||||
let destinationKey = DestinationIntKey(name: "modern.rollback", domain: .userDefaults(suite: nil))
|
||||
|
||||
try await router.set("not-a-number", for: sourceKey)
|
||||
|
||||
let migration = DefaultTransformingMigration(
|
||||
destinationKey: destinationKey,
|
||||
sourceKey: sourceKey
|
||||
) { _ in
|
||||
throw MigrationError.transformationFailed("Invalid integer")
|
||||
}
|
||||
|
||||
let result = try await migration.migrate(using: router, context: MigrationContext())
|
||||
#expect(result.success == false)
|
||||
#expect(try await router.exists(sourceKey) == true)
|
||||
#expect(try await router.exists(destinationKey) == false)
|
||||
}
|
||||
|
||||
@Test func watchAvailabilityBlocksMigrationTest() async throws {
|
||||
let legacyKey = LegacyStringKey(name: "legacy.watch", domain: .userDefaults(suite: nil))
|
||||
let destinationKey = PhoneOnlyKey(name: "modern.watch", domain: .userDefaults(suite: nil))
|
||||
|
||||
let migration = SimpleLegacyMigration(destinationKey: destinationKey, sourceKey: .key(legacyKey))
|
||||
let deviceInfo = DeviceInfo(
|
||||
platform: .watchOS,
|
||||
systemVersion: "10.0",
|
||||
model: "Watch",
|
||||
isSimulator: true
|
||||
)
|
||||
let context = MigrationContext(deviceInfo: deviceInfo)
|
||||
|
||||
let shouldMigrate = try await migration.shouldMigrate(using: router, context: context)
|
||||
#expect(shouldMigrate == false)
|
||||
}
|
||||
|
||||
@Test func largeDataMigrationTest() async throws {
|
||||
let legacyKey = LegacyStringKey(name: "legacy.large", domain: .userDefaults(suite: nil))
|
||||
let modernKey = ModernStringKey(
|
||||
name: "modern.large",
|
||||
domain: .userDefaults(suite: nil),
|
||||
legacyKey: .key(legacyKey)
|
||||
)
|
||||
|
||||
let largeValue = String(repeating: "a", count: 1_200_000)
|
||||
try await router.set(largeValue, for: legacyKey)
|
||||
|
||||
let migration = SimpleLegacyMigration(destinationKey: modernKey, sourceKey: .key(legacyKey))
|
||||
let result = try await migration.migrate(using: router, context: MigrationContext())
|
||||
#expect(result.success == true)
|
||||
|
||||
let migratedValue = try await router.get(modernKey)
|
||||
#expect(migratedValue == largeValue)
|
||||
}
|
||||
|
||||
@Test func typeErasureMigrationTest() async throws {
|
||||
let legacyKey = LegacyStringKey(name: "legacy.erased", domain: .userDefaults(suite: nil))
|
||||
let modernKey = ModernStringKey(
|
||||
name: "modern.erased",
|
||||
domain: .userDefaults(suite: nil),
|
||||
legacyKey: .key(legacyKey)
|
||||
)
|
||||
|
||||
try await router.set("erased", for: legacyKey)
|
||||
let migration = AnyStorageMigration(
|
||||
SimpleLegacyMigration(destinationKey: modernKey, sourceKey: .key(legacyKey))
|
||||
)
|
||||
|
||||
let shouldMigrate = try await migration.shouldMigrate(using: router, context: MigrationContext())
|
||||
#expect(shouldMigrate == true)
|
||||
|
||||
let result = try await migration.migrate(using: router, context: MigrationContext())
|
||||
#expect(result.success == true)
|
||||
}
|
||||
}
|
||||
120
Tests/LocalDataTests/MigrationIntegrationTests.swift
Normal file
120
Tests/LocalDataTests/MigrationIntegrationTests.swift
Normal file
@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LocalData
|
||||
|
||||
private struct LegacyIntegrationKey: StorageKey {
|
||||
typealias Value = String
|
||||
let name: String
|
||||
let domain: StorageDomain
|
||||
let security: SecurityPolicy = .none
|
||||
let serializer: Serializer<String> = .json
|
||||
let owner: String = "Legacy"
|
||||
let description: String = "Legacy integration key"
|
||||
let availability: PlatformAvailability = .all
|
||||
let syncPolicy: SyncPolicy = .never
|
||||
}
|
||||
|
||||
private struct ModernIntegrationKey: StorageKey {
|
||||
typealias Value = String
|
||||
let name: String
|
||||
let domain: StorageDomain
|
||||
let security: SecurityPolicy = .keychain(accessibility: .afterFirstUnlock, accessControl: .none)
|
||||
let serializer: Serializer<String> = .json
|
||||
let owner: String = "Modern"
|
||||
let description: String = "Modern integration key"
|
||||
let availability: PlatformAvailability = .all
|
||||
let syncPolicy: SyncPolicy = .never
|
||||
let legacyKey: AnyStorageKey?
|
||||
|
||||
var migration: AnyStorageMigration? {
|
||||
guard let legacyKey else { return nil }
|
||||
return AnyStorageMigration(
|
||||
SimpleLegacyMigration(destinationKey: self, sourceKey: legacyKey)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IntegrationCatalog: StorageKeyCatalog {
|
||||
let allKeys: [AnyStorageKey]
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct MigrationIntegrationTests {
|
||||
private let router: StorageRouter
|
||||
|
||||
init() {
|
||||
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "MigrationIntegrationTests-\(UUID().uuidString)")
|
||||
router = StorageRouter(
|
||||
keychain: MockKeychainHelper(),
|
||||
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
|
||||
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
|
||||
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "MigrationIntegrationTests.\(UUID().uuidString)")!)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func endToEndMigrationTest() async throws {
|
||||
let legacyKey = LegacyIntegrationKey(name: "legacy.integration", domain: .userDefaults(suite: nil))
|
||||
let modernKey = ModernIntegrationKey(
|
||||
name: "modern.integration",
|
||||
domain: .keychain(service: "test.migration"),
|
||||
legacyKey: .key(legacyKey)
|
||||
)
|
||||
|
||||
try await router.set("token", for: legacyKey)
|
||||
let migrated = try await router.get(modernKey)
|
||||
#expect(migrated == "token")
|
||||
|
||||
let legacyExists = try await router.exists(legacyKey)
|
||||
#expect(legacyExists == false)
|
||||
}
|
||||
|
||||
@Test func migrationRegistrationTest() async throws {
|
||||
let legacyKey = LegacyIntegrationKey(name: "legacy.catalog", domain: .userDefaults(suite: nil))
|
||||
let modernKey = ModernIntegrationKey(
|
||||
name: "modern.catalog",
|
||||
domain: .keychain(service: "test.migration"),
|
||||
legacyKey: .key(legacyKey)
|
||||
)
|
||||
|
||||
try await router.set("catalog", for: legacyKey)
|
||||
|
||||
let catalog = IntegrationCatalog(allKeys: [.key(modernKey)])
|
||||
try await router.registerCatalog(catalog, migrateImmediately: true)
|
||||
|
||||
let migrated = try await router.get(modernKey)
|
||||
#expect(migrated == "catalog")
|
||||
}
|
||||
|
||||
@Test func concurrentMigrationTest() async throws {
|
||||
let legacyKey = LegacyIntegrationKey(name: "legacy.concurrent", domain: .userDefaults(suite: nil))
|
||||
let modernKey = ModernIntegrationKey(
|
||||
name: "modern.concurrent",
|
||||
domain: .keychain(service: "test.migration"),
|
||||
legacyKey: .key(legacyKey)
|
||||
)
|
||||
|
||||
try await router.set("value", for: legacyKey)
|
||||
|
||||
async let first: MigrationResult = router.forceMigration(for: modernKey)
|
||||
async let second: MigrationResult = router.forceMigration(for: modernKey)
|
||||
|
||||
let firstResult = try await first
|
||||
let secondResult = try await second
|
||||
let results = [firstResult, secondResult]
|
||||
#expect(results.contains(where: { $0.success }))
|
||||
}
|
||||
|
||||
@Test func migrationFailureResultTest() async throws {
|
||||
let destinationKey = ModernIntegrationKey(
|
||||
name: "modern.failure",
|
||||
domain: .userDefaults(suite: nil),
|
||||
legacyKey: nil
|
||||
)
|
||||
|
||||
let migration = FailingMigration(destinationKey: destinationKey, error: .validationFailed("Invalid"))
|
||||
let result = try await migration.migrate(using: router, context: MigrationContext())
|
||||
|
||||
#expect(result.success == false)
|
||||
#expect(result.errors.first == .validationFailed("Invalid"))
|
||||
}
|
||||
}
|
||||
161
Tests/LocalDataTests/MigrationProtocolTests.swift
Normal file
161
Tests/LocalDataTests/MigrationProtocolTests.swift
Normal file
@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LocalData
|
||||
|
||||
private struct LegacyStringKey: StorageKey {
|
||||
typealias Value = String
|
||||
let name: String
|
||||
let domain: StorageDomain
|
||||
let security: SecurityPolicy = .none
|
||||
let serializer: Serializer<String> = .json
|
||||
let owner: String = "Legacy"
|
||||
let description: String = "Legacy key"
|
||||
let availability: PlatformAvailability = .all
|
||||
let syncPolicy: SyncPolicy = .never
|
||||
}
|
||||
|
||||
private struct DestinationStringKey: StorageKey {
|
||||
typealias Value = String
|
||||
let name: String
|
||||
let domain: StorageDomain
|
||||
let security: SecurityPolicy = .keychain(accessibility: .afterFirstUnlock, accessControl: .none)
|
||||
let serializer: Serializer<String> = .json
|
||||
let owner: String = "Destination"
|
||||
let description: String = "Destination key"
|
||||
let availability: PlatformAvailability = .all
|
||||
let syncPolicy: SyncPolicy = .never
|
||||
}
|
||||
|
||||
private struct SourceStringKey: StorageKey {
|
||||
typealias Value = String
|
||||
let name: String
|
||||
let domain: StorageDomain
|
||||
let security: SecurityPolicy = .none
|
||||
let serializer: Serializer<String> = .json
|
||||
let owner: String = "Source"
|
||||
let description: String = "Source key"
|
||||
let availability: PlatformAvailability = .all
|
||||
let syncPolicy: SyncPolicy = .never
|
||||
}
|
||||
|
||||
private struct DestinationIntKey: StorageKey {
|
||||
typealias Value = Int
|
||||
let name: String
|
||||
let domain: StorageDomain
|
||||
let security: SecurityPolicy = .none
|
||||
let serializer: Serializer<Int> = .json
|
||||
let owner: String = "Destination"
|
||||
let description: String = "Destination int key"
|
||||
let availability: PlatformAvailability = .all
|
||||
let syncPolicy: SyncPolicy = .never
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct MigrationProtocolTests {
|
||||
private let router: StorageRouter
|
||||
|
||||
init() {
|
||||
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "MigrationProtocolTests-\(UUID().uuidString)")
|
||||
router = StorageRouter(
|
||||
keychain: MockKeychainHelper(),
|
||||
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
|
||||
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
|
||||
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "MigrationProtocolTests.\(UUID().uuidString)")!)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func simpleLegacyMigrationTest() async throws {
|
||||
let legacyKey = LegacyStringKey(name: "legacy.simple", domain: .userDefaults(suite: nil))
|
||||
let destinationKey = DestinationStringKey(name: "modern.simple", domain: .keychain(service: "test.migration"))
|
||||
try await router.set("value", for: legacyKey)
|
||||
|
||||
let migration = SimpleLegacyMigration(destinationKey: destinationKey, sourceKey: .key(legacyKey))
|
||||
let context = MigrationContext(appVersion: "1.0")
|
||||
|
||||
let shouldMigrate = try await migration.shouldMigrate(using: router, context: context)
|
||||
#expect(shouldMigrate == true)
|
||||
|
||||
let result = try await migration.migrate(using: router, context: context)
|
||||
#expect(result.success == true)
|
||||
|
||||
let migratedValue = try await router.get(destinationKey)
|
||||
#expect(migratedValue == "value")
|
||||
}
|
||||
|
||||
@Test func conditionalMigrationTest() async throws {
|
||||
let legacyKey = LegacyStringKey(name: "legacy.conditional", domain: .userDefaults(suite: nil))
|
||||
let destinationKey = DestinationStringKey(name: "modern.conditional", domain: .keychain(service: "test.migration"))
|
||||
try await router.set("value", for: legacyKey)
|
||||
|
||||
let fallback = AnyStorageMigration(
|
||||
SimpleLegacyMigration(destinationKey: destinationKey, sourceKey: .key(legacyKey))
|
||||
)
|
||||
let migration = AppVersionConditionalMigration(
|
||||
destinationKey: destinationKey,
|
||||
minAppVersion: "2.0",
|
||||
fallbackMigration: fallback
|
||||
)
|
||||
|
||||
let context = MigrationContext(appVersion: "1.0")
|
||||
let shouldMigrate = try await migration.shouldMigrate(using: router, context: context)
|
||||
#expect(shouldMigrate == true)
|
||||
|
||||
let result = try await migration.migrate(using: router, context: context)
|
||||
#expect(result.success == true)
|
||||
}
|
||||
|
||||
@Test func transformingMigrationTest() async throws {
|
||||
let sourceKey = SourceStringKey(name: "legacy.transform", domain: .userDefaults(suite: nil))
|
||||
let destinationKey = DestinationIntKey(name: "modern.transform", domain: .userDefaults(suite: nil))
|
||||
try await router.set("42", for: sourceKey)
|
||||
|
||||
let migration = DefaultTransformingMigration(
|
||||
destinationKey: destinationKey,
|
||||
sourceKey: sourceKey
|
||||
) { value in
|
||||
guard let intValue = Int(value) else {
|
||||
throw MigrationError.transformationFailed("Invalid integer")
|
||||
}
|
||||
return intValue
|
||||
}
|
||||
|
||||
let result = try await migration.migrate(using: router, context: MigrationContext())
|
||||
#expect(result.success == true)
|
||||
|
||||
let migratedValue = try await router.get(destinationKey)
|
||||
#expect(migratedValue == 42)
|
||||
}
|
||||
|
||||
@Test func aggregatingMigrationTest() async throws {
|
||||
let sourceKeyA = SourceStringKey(name: "legacy.aggregate.a", domain: .userDefaults(suite: nil))
|
||||
let sourceKeyB = SourceStringKey(name: "legacy.aggregate.b", domain: .userDefaults(suite: nil))
|
||||
let destinationKey = DestinationStringKey(name: "modern.aggregate", domain: .userDefaults(suite: nil))
|
||||
|
||||
try await router.set("alpha", for: sourceKeyA)
|
||||
try await router.set("beta", for: sourceKeyB)
|
||||
|
||||
let migration = DefaultAggregatingMigration(
|
||||
destinationKey: destinationKey,
|
||||
sourceKeys: [.key(sourceKeyA), .key(sourceKeyB)]
|
||||
) { sources in
|
||||
let values = sources.compactMap { $0.value as? String }
|
||||
return values.joined(separator: ",")
|
||||
}
|
||||
|
||||
let result = try await migration.migrate(using: router, context: MigrationContext())
|
||||
#expect(result.success == true)
|
||||
|
||||
let migratedValue = try await router.get(destinationKey)
|
||||
#expect(migratedValue.contains("alpha"))
|
||||
#expect(migratedValue.contains("beta"))
|
||||
}
|
||||
|
||||
@Test func migrationErrorHandlingTest() async throws {
|
||||
let destinationKey = DestinationStringKey(name: "modern.error", domain: .userDefaults(suite: nil))
|
||||
let migration = FailingMigration(destinationKey: destinationKey, error: .transformationFailed("Failed"))
|
||||
let result = try await migration.migrate(using: router, context: MigrationContext())
|
||||
|
||||
#expect(result.success == false)
|
||||
#expect(result.errors.first == .transformationFailed("Failed"))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
42
Tests/LocalDataTests/Mocks/MockMigration.swift
Normal file
42
Tests/LocalDataTests/Mocks/MockMigration.swift
Normal file
@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
@testable import LocalData
|
||||
|
||||
struct MockMigration<Destination: StorageKey>: StorageMigration {
|
||||
typealias DestinationKey = Destination
|
||||
|
||||
let destinationKey: Destination
|
||||
let shouldSucceed: Bool
|
||||
let shouldMigrateResult: Bool
|
||||
let migrationDelay: TimeInterval
|
||||
|
||||
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
return shouldMigrateResult
|
||||
}
|
||||
|
||||
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||||
try await Task.sleep(for: .seconds(migrationDelay))
|
||||
|
||||
if shouldSucceed {
|
||||
return MigrationResult(success: true, migratedCount: 1)
|
||||
}
|
||||
|
||||
return MigrationResult(
|
||||
success: false,
|
||||
errors: [.transformationFailed("Mock failure")]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingMigration<Destination: StorageKey>: StorageMigration {
|
||||
typealias DestinationKey = Destination
|
||||
|
||||
let destinationKey: Destination
|
||||
let error: MigrationError
|
||||
|
||||
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool { true }
|
||||
|
||||
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
|
||||
MigrationResult(success: false, errors: [error])
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user