Update LocalData.swift + docs

Summary:
- Sources: LocalData.swift
- Docs: Design, Migration, Migration_Refactor_Plan_Clean
- Added symbols: extension StorageKey, typealias Value, struct StorageKey, struct SimpleLegacyMigration, struct AppVersionConditionalMigration, func transform (+8 more)
- Removed symbols: struct MyNewKey, typealias DestinationKey, extension MyNewKey, protocol StorageKey, extension StorageKey, struct SimpleLegacyMigration (+16 more)

Stats:
- 4 files changed, 146 insertions(+), 132 deletions(-)
This commit is contained in:
Matt Bruce 2026-01-16 22:23:25 -06:00
parent 51faaf4937
commit fff33f2a08
4 changed files with 145 additions and 131 deletions

View File

@ -9,7 +9,7 @@
The central `actor` that coordinates all storage operations. It acts as the primary API surface and handles routing, catalog validation, and migration. The central `actor` that coordinates all storage operations. It acts as the primary API surface and handles routing, catalog validation, and migration.
### StorageKey ### StorageKey
A protocol that defines the metadata for a single piece of persistent data. A generic struct that defines the metadata for a single piece of persistent data.
- **Value**: The type of the data (Codable). - **Value**: The type of the data (Codable).
- **Domain**: Where the data is stored (UserDefaults, Keychain, FileSystem, etc.). - **Domain**: Where the data is stored (UserDefaults, Keychain, FileSystem, etc.).
- **Security**: How the data is secured (None, Keychain-native, or custom Encryption). - **Security**: How the data is secured (None, Keychain-native, or custom Encryption).

View File

@ -1,7 +1,7 @@
# LocalData Migration Guide # LocalData Migration Guide
## Overview ## Overview
`LocalData` provides protocol-based migration support to move data from legacy storage locations to modern `StorageKey` definitions. `LocalData` provides protocol-based migration support to move data from legacy storage locations to modern `StorageKey` values.
## Automatic Migration ## Automatic Migration
When calling `get(_:)` on a key, the `StorageRouter` automatically: When calling `get(_:)` on a key, the `StorageRouter` automatically:
@ -25,16 +25,30 @@ This iterates through all keys in the catalog and calls `forceMigration(for:)` o
### Simple Legacy Migration ### Simple Legacy Migration
For 1:1 migrations, attach a `SimpleLegacyMigration`: For 1:1 migrations, attach a `SimpleLegacyMigration`:
```swift ```swift
struct MyNewKey: StorageKey { extension StorageKey where Value == String {
// ... static let legacyToken = StorageKey(
var migration: AnyStorageMigration? { name: "old_key_name",
domain: .userDefaults(suite: nil),
security: .none,
serializer: .json,
owner: "MigrationDemo",
description: "Legacy token stored in UserDefaults."
)
static let modernToken = StorageKey(
name: "modern_token",
domain: .keychain(service: "com.myapp"),
owner: "MigrationDemo",
description: "Modern token stored in Keychain.",
migration: { key in
AnyStorageMigration( AnyStorageMigration(
SimpleLegacyMigration( SimpleLegacyMigration(
destinationKey: self, destinationKey: key,
sourceKey: .key(LegacyKey(name: "old_key_name", domain: .userDefaults(suite: nil))) sourceKey: .key(StorageKey.legacyToken)
) )
) )
} }
)
} }
``` ```
@ -42,9 +56,9 @@ struct MyNewKey: StorageKey {
For complex scenarios, attach an explicit migration: For complex scenarios, attach an explicit migration:
```swift ```swift
struct MyMigration: StorageMigration { struct MyMigration: StorageMigration {
typealias DestinationKey = MyNewKey typealias Value = String
let destinationKey = MyNewKey() let destinationKey = StorageKey.modernToken
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool { func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
try await router.exists(destinationKey) try await router.exists(destinationKey)
@ -56,9 +70,13 @@ struct MyMigration: StorageMigration {
} }
} }
extension MyNewKey { extension StorageKey where Value == String {
var migration: AnyStorageMigration? { static let modernToken = StorageKey(
AnyStorageMigration(MyMigration()) name: "modern_token",
} domain: .keychain(service: "com.myapp"),
owner: "MigrationDemo",
description: "Modern token stored in Keychain.",
migration: { _ in AnyStorageMigration(MyMigration()) }
)
} }
``` ```

View File

@ -61,10 +61,10 @@ Follow AGENTS.md Clean Architecture and File Organization Principles for all new
```swift ```swift
/// Core migration protocol with high-level methods /// Core migration protocol with high-level methods
public protocol StorageMigration: Sendable { public protocol StorageMigration: Sendable {
associatedtype DestinationKey: StorageKey associatedtype Value: Codable & Sendable
/// The destination storage key where migrated data will be stored /// The destination storage key where migrated data will be stored
var destinationKey: DestinationKey { get } var destinationKey: StorageKey<Value> { get }
/// Validate if migration should proceed (conditional logic) /// Validate if migration should proceed (conditional logic)
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool
@ -179,27 +179,47 @@ public struct MigrationContext: Sendable {
} }
``` ```
### 1.2 Update StorageKey Protocol ### 1.2 Update StorageKey Type
#### Modify `Sources/LocalData/Protocols/StorageKey.swift` #### Modify `Sources/LocalData/Models/StorageKey.swift`
```swift ```swift
public protocol StorageKey: Sendable, CustomStringConvertible { public struct StorageKey<Value: Codable & Sendable>: Sendable, CustomStringConvertible {
associatedtype Value: Codable & Sendable public let name: String
public let domain: StorageDomain
public let security: SecurityPolicy
public let serializer: Serializer<Value>
public let owner: String
public let description: String
public let availability: PlatformAvailability
public let syncPolicy: SyncPolicy
var name: String { get } private let migrationBuilder: (@Sendable (StorageKey<Value>) -> AnyStorageMigration?)?
var domain: StorageDomain { get }
var security: SecurityPolicy { get }
var serializer: Serializer<Value> { get }
var owner: String { get }
var availability: PlatformAvailability { get }
var syncPolicy: SyncPolicy { get }
/// Optional migration for simple or complex scenarios public init(
var migration: AnyStorageMigration? { get } name: String,
domain: StorageDomain,
security: SecurityPolicy = .recommended,
serializer: Serializer<Value> = .json,
owner: String,
description: String,
availability: PlatformAvailability = .all,
syncPolicy: SyncPolicy = .never,
migration: (@Sendable (StorageKey<Value>) -> AnyStorageMigration?)? = nil
) {
self.name = name
self.domain = domain
self.security = security
self.serializer = serializer
self.owner = owner
self.description = description
self.availability = availability
self.syncPolicy = syncPolicy
self.migrationBuilder = migration
} }
extension StorageKey { public var migration: AnyStorageMigration? {
public var migration: AnyStorageMigration? { nil } migrationBuilder?(self)
}
} }
``` ```
@ -212,7 +232,7 @@ public struct AnyStorageKey: Sendable {
public internal(set) var migration: AnyStorageMigration? public internal(set) var migration: AnyStorageMigration?
private let migrateAction: @Sendable (StorageRouter) async throws -> Void private let migrateAction: @Sendable (StorageRouter) async throws -> Void
public init<Key: StorageKey>(_ key: Key) { public init<Value>(_ key: StorageKey<Value>) {
self.descriptor = .from(key) self.descriptor = .from(key)
self.migration = key.migration self.migration = key.migration
self.migrateAction = { router in self.migrateAction = { router in
@ -231,13 +251,11 @@ public struct AnyStorageKey: Sendable {
#### `Sources/LocalData/Migrations/SimpleLegacyMigration.swift` #### `Sources/LocalData/Migrations/SimpleLegacyMigration.swift`
```swift ```swift
/// Simple 1:1 legacy migration /// Simple 1:1 legacy migration
public struct SimpleLegacyMigration<Destination: StorageKey>: StorageMigration { public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration {
public typealias DestinationKey = Destination public let destinationKey: StorageKey<Value>
public let destinationKey: Destination
public let sourceKey: AnyStorageKey public let sourceKey: AnyStorageKey
public init(destinationKey: Destination, sourceKey: AnyStorageKey) { public init(destinationKey: StorageKey<Value>, sourceKey: AnyStorageKey) {
self.destinationKey = destinationKey self.destinationKey = destinationKey
self.sourceKey = sourceKey self.sourceKey = sourceKey
} }
@ -290,13 +308,12 @@ public struct SimpleLegacyMigration<Destination: StorageKey>: StorageMigration {
#### `Sources/LocalData/Migrations/AppVersionConditionalMigration.swift` #### `Sources/LocalData/Migrations/AppVersionConditionalMigration.swift`
```swift ```swift
/// Conditional migration for app version-based migration /// Conditional migration for app version-based migration
public struct AppVersionConditionalMigration<Destination: StorageKey>: ConditionalMigration { public struct AppVersionConditionalMigration<Value: Codable & Sendable>: ConditionalMigration {
public typealias DestinationKey = Destination public let destinationKey: StorageKey<Value>
public let destinationKey: Destination
public let minAppVersion: String public let minAppVersion: String
public let fallbackMigration: AnyStorageMigration public let fallbackMigration: AnyStorageMigration
public init(destinationKey: Destination, minAppVersion: String, fallbackMigration: AnyStorageMigration) { public init(destinationKey: StorageKey<Value>, minAppVersion: String, fallbackMigration: AnyStorageMigration) {
self.destinationKey = destinationKey self.destinationKey = destinationKey
self.minAppVersion = minAppVersion self.minAppVersion = minAppVersion
self.fallbackMigration = fallbackMigration self.fallbackMigration = fallbackMigration
@ -319,24 +336,21 @@ public struct AppVersionConditionalMigration<Destination: StorageKey>: Condition
```swift ```swift
/// Migration protocol that supports data transformation during migration /// Migration protocol that supports data transformation during migration
public protocol TransformingMigration: StorageMigration { public protocol TransformingMigration: StorageMigration {
associatedtype SourceKey: StorageKey associatedtype SourceValue: Codable & Sendable
var sourceKey: SourceKey { get } var sourceKey: StorageKey<SourceValue> { get }
func transform(_ source: SourceKey.Value) async throws -> DestinationKey.Value func transform(_ source: SourceValue) async throws -> Value
} }
public struct DefaultTransformingMigration<Source: StorageKey, Destination: StorageKey>: TransformingMigration { public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, DestinationValue: Codable & Sendable>: TransformingMigration {
public typealias SourceKey = Source public let destinationKey: StorageKey<DestinationValue>
public typealias DestinationKey = Destination public let sourceKey: StorageKey<SourceValue>
public let transform: (SourceValue) async throws -> DestinationValue
public let destinationKey: Destination
public let sourceKey: Source
public let transform: (Source.Value) async throws -> Destination.Value
public init( public init(
destinationKey: Destination, destinationKey: StorageKey<DestinationValue>,
sourceKey: Source, sourceKey: StorageKey<SourceValue>,
transform: @escaping (Source.Value) async throws -> Destination.Value transform: @escaping (SourceValue) async throws -> DestinationValue
) { ) {
self.destinationKey = destinationKey self.destinationKey = destinationKey
self.sourceKey = sourceKey self.sourceKey = sourceKey
@ -384,20 +398,18 @@ public struct DefaultTransformingMigration<Source: StorageKey, Destination: Stor
/// Migration protocol that combines multiple sources into single destination /// Migration protocol that combines multiple sources into single destination
public protocol AggregatingMigration: StorageMigration { public protocol AggregatingMigration: StorageMigration {
var sourceKeys: [AnyStorageKey] { get } var sourceKeys: [AnyStorageKey] { get }
func aggregate(_ sources: [AnyCodable]) async throws -> DestinationKey.Value func aggregate(_ sources: [AnyCodable]) async throws -> Value
} }
public struct DefaultAggregatingMigration<Destination: StorageKey>: AggregatingMigration { public struct DefaultAggregatingMigration<Value: Codable & Sendable>: AggregatingMigration {
public typealias DestinationKey = Destination public let destinationKey: StorageKey<Value>
public let destinationKey: Destination
public let sourceKeys: [AnyStorageKey] public let sourceKeys: [AnyStorageKey]
public let aggregate: ([AnyCodable]) async throws -> Destination.Value public let aggregate: ([AnyCodable]) async throws -> Value
public init( public init(
destinationKey: Destination, destinationKey: StorageKey<Value>,
sourceKeys: [AnyStorageKey], sourceKeys: [AnyStorageKey],
aggregate: @escaping ([AnyCodable]) async throws -> Destination.Value aggregate: @escaping ([AnyCodable]) async throws -> Value
) { ) {
self.destinationKey = destinationKey self.destinationKey = destinationKey
self.sourceKeys = sourceKeys self.sourceKeys = sourceKeys
@ -524,7 +536,7 @@ internal func recordMigration(for descriptor: StorageKeyDescriptor) {
**Enhanced `get` method:** **Enhanced `get` method:**
```swift ```swift
public func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value { public func get<Value>(_ key: StorageKey<Value>) async throws -> Value {
try validateCatalogRegistration(for: key) try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
@ -570,12 +582,12 @@ private func migrateAllRegisteredKeys() async throws {
**New Methods:** **New Methods:**
```swift ```swift
/// Get migration history for a key /// Get migration history for a key
public func migrationHistory<Key: StorageKey>(for key: Key) async -> Date? { public func migrationHistory<Value>(for key: StorageKey<Value>) async -> Date? {
migrationHistory[key.name] migrationHistory[key.name]
} }
/// Force migration for a specific key using its attached migration /// Force migration for a specific key using its attached migration
public func forceMigration<Key: StorageKey>(for key: Key) async throws -> MigrationResult { public func forceMigration<Value>(for key: StorageKey<Value>) async throws -> MigrationResult {
guard let migration = key.migration else { guard let migration = key.migration else {
throw MigrationError.sourceDataNotFound throw MigrationError.sourceDataNotFound
} }
@ -871,39 +883,32 @@ struct UnifiedSettings: Codable {
**`ConditionalMigrationKeys.swift`** **`ConditionalMigrationKeys.swift`**
```swift ```swift
extension StorageKeys { extension StorageKey where Value == UserProfile {
struct LegacyConditionalProfileKey: StorageKey { static let legacyConditionalProfile = StorageKey(
typealias Value = UserProfile name: "legacy_conditional_profile",
domain: .userDefaults(suite: nil),
security: .none,
serializer: .json,
owner: "MigrationDemo",
description: "Legacy profile for conditional migration demo.",
availability: .all,
syncPolicy: .never
)
let name = "legacy_conditional_profile" static let modernConditionalProfile = StorageKey(
let domain: StorageDomain = .userDefaults(suite: nil) name: "modern_conditional_profile",
let security: SecurityPolicy = .none domain: .keychain(service: "com.mbrucedogs.securestorage"),
let serializer: Serializer<UserProfile> = .json security: .keychain(
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, accessibility: .afterFirstUnlock,
accessControl: .userPresence accessControl: .userPresence
),
serializer: .json,
owner: "MigrationDemo",
description: "Modern profile for conditional migration demo.",
availability: .all,
syncPolicy: .never,
migration: { _ in AnyStorageMigration(ConditionalProfileMigration()) }
) )
let serializer: Serializer<UserProfile> = .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())
}
}
} }
``` ```
@ -949,9 +954,9 @@ The `StorageMigration` protocol provides the foundation for all migration types:
```swift ```swift
public protocol StorageMigration: Sendable { public protocol StorageMigration: Sendable {
associatedtype DestinationKey: StorageKey associatedtype Value: Codable & Sendable
var destinationKey: DestinationKey { get } var destinationKey: StorageKey<Value> { get }
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult
} }
@ -1197,27 +1202,25 @@ conditional behavior.
### 1. Define Keys ### 1. Define Keys
Extend `StorageKeys` with your own key types: Extend `StorageKey` with typed static keys:
```swift ```swift
import LocalData import LocalData
extension StorageKeys { extension StorageKey where Value == String {
struct UserTokenKey: StorageKey { static let userToken = StorageKey(
typealias Value = String name: "user_token",
domain: .keychain(service: "com.myapp"),
let name = "user_token" security: .keychain(
let domain: StorageDomain = .keychain(service: "com.myapp")
let security: SecurityPolicy = .keychain(
accessibility: .afterFirstUnlock, accessibility: .afterFirstUnlock,
accessControl: .biometryAny accessControl: .biometryAny
),
serializer: .json,
owner: "AuthService",
description: "Stores the current user auth token.",
availability: .phoneOnly,
syncPolicy: .never
) )
let serializer: Serializer<String> = .json
let owner = "AuthService"
let description = "Stores the current user auth token."
let availability: PlatformAvailability = .phoneOnly
let syncPolicy: SyncPolicy = .never
}
} }
``` ```
@ -1265,7 +1268,7 @@ extension UserTokenKey {
```swift ```swift
// Save // Save
let key = StorageKeys.UserTokenKey() let key = StorageKey.userToken
try await StorageRouter.shared.set("token123", for: key) try await StorageRouter.shared.set("token123", for: key)
// Retrieve (triggers automatic migration if needed) // Retrieve (triggers automatic migration if needed)
@ -1279,7 +1282,7 @@ try await StorageRouter.shared.remove(key)
```swift ```swift
// Force migration // Force migration
let result = try await StorageRouter.shared.forceMigration(for: UserTokenKey()) let result = try await StorageRouter.shared.forceMigration(for: StorageKey.userToken)
``` ```
``` ```
@ -1380,10 +1383,8 @@ struct MigrationIntegrationTests {
#### `Tests/LocalDataTests/Mocks/MockMigration.swift` #### `Tests/LocalDataTests/Mocks/MockMigration.swift`
```swift ```swift
/// Mock migration for testing /// Mock migration for testing
struct MockMigration<Destination: StorageKey>: StorageMigration { struct MockMigration<Value: Codable & Sendable>: StorageMigration {
typealias DestinationKey = Destination let destinationKey: StorageKey<Value>
let destinationKey: Destination
let shouldSucceed: Bool let shouldSucceed: Bool
let shouldMigrateResult: Bool let shouldMigrateResult: Bool
let migrationDelay: TimeInterval let migrationDelay: TimeInterval
@ -1408,10 +1409,8 @@ struct MockMigration<Destination: StorageKey>: StorageMigration {
} }
/// Failing migration for error testing /// Failing migration for error testing
struct FailingMigration<Destination: StorageKey>: StorageMigration { struct FailingMigration<Value: Codable & Sendable>: StorageMigration {
typealias DestinationKey = Destination let destinationKey: StorageKey<Value>
let destinationKey: Destination
let error: MigrationError let error: MigrationError
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool { true } func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool { true }
@ -1547,7 +1546,7 @@ public enum MigrationUtils {
## Migration Checklist ## Migration Checklist
- [x] Create core protocol files - [x] Create core protocol files
- [x] Update StorageKey protocol with migration - [x] Update StorageKey type with migration
- [x] Create concrete migration implementations - [x] Create concrete migration implementations
- [x] Update StorageRouter integration with simple and advanced migration handling - [x] Update StorageRouter integration with simple and advanced migration handling
- [x] Add WatchOS/sync defaults to StorageMigration - [x] Add WatchOS/sync defaults to StorageMigration

View File

@ -1,3 +0,0 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book