Compare commits

..

No commits in common. "f6bc816a5d34a8c59cf6628f5a7f3d7e6479f9f3" and "e5bf9550a43eccf6e3eda5daadb076d798eb8aed" have entirely different histories.

20 changed files with 260 additions and 293 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 generic struct that defines the metadata for a single piece of persistent data. A protocol 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` values. `LocalData` provides protocol-based migration support to move data from legacy storage locations to modern `StorageKey` definitions.
## Automatic Migration ## Automatic Migration
When calling `get(_:)` on a key, the `StorageRouter` automatically: When calling `get(_:)` on a key, the `StorageRouter` automatically:
@ -25,30 +25,16 @@ 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
extension StorageKey where Value == String { struct MyNewKey: StorageKey {
static let legacyToken = StorageKey( // ...
name: "old_key_name", var migration: AnyStorageMigration? {
domain: .userDefaults(suite: nil), AnyStorageMigration(
security: .none, SimpleLegacyMigration(
serializer: .json, destinationKey: self,
owner: "MigrationDemo", sourceKey: .key(LegacyKey(name: "old_key_name", domain: .userDefaults(suite: nil)))
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(
SimpleLegacyMigration(
destinationKey: key,
sourceKey: .key(StorageKey.legacyToken)
)
) )
} )
) }
} }
``` ```
@ -56,9 +42,9 @@ extension StorageKey where Value == String {
For complex scenarios, attach an explicit migration: For complex scenarios, attach an explicit migration:
```swift ```swift
struct MyMigration: StorageMigration { struct MyMigration: StorageMigration {
typealias Value = String typealias DestinationKey = MyNewKey
let destinationKey = StorageKey.modernToken let destinationKey = MyNewKey()
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)
@ -70,13 +56,9 @@ struct MyMigration: StorageMigration {
} }
} }
extension StorageKey where Value == String { extension MyNewKey {
static let modernToken = StorageKey( var migration: AnyStorageMigration? {
name: "modern_token", AnyStorageMigration(MyMigration())
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 Value: Codable & Sendable associatedtype DestinationKey: StorageKey
/// The destination storage key where migrated data will be stored /// The destination storage key where migrated data will be stored
var destinationKey: StorageKey<Value> { get } var destinationKey: DestinationKey { 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,47 +179,27 @@ public struct MigrationContext: Sendable {
} }
``` ```
### 1.2 Update StorageKey Type ### 1.2 Update StorageKey Protocol
#### Modify `Sources/LocalData/Models/StorageKey.swift` #### Modify `Sources/LocalData/Protocols/StorageKey.swift`
```swift ```swift
public struct StorageKey<Value: Codable & Sendable>: Sendable, CustomStringConvertible { public protocol StorageKey: Sendable, CustomStringConvertible {
public let name: String associatedtype Value: Codable & Sendable
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
private let migrationBuilder: (@Sendable (StorageKey<Value>) -> AnyStorageMigration?)? var name: String { get }
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 }
public init( /// Optional migration for simple or complex scenarios
name: String, var migration: AnyStorageMigration? { get }
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
}
public var migration: AnyStorageMigration? { extension StorageKey {
migrationBuilder?(self) public var migration: AnyStorageMigration? { nil }
}
} }
``` ```
@ -232,7 +212,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<Value>(_ key: StorageKey<Value>) { public init<Key: StorageKey>(_ key: Key) {
self.descriptor = .from(key) self.descriptor = .from(key)
self.migration = key.migration self.migration = key.migration
self.migrateAction = { router in self.migrateAction = { router in
@ -251,11 +231,13 @@ 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<Value: Codable & Sendable>: StorageMigration { public struct SimpleLegacyMigration<Destination: StorageKey>: StorageMigration {
public let destinationKey: StorageKey<Value> public typealias DestinationKey = Destination
public let destinationKey: Destination
public let sourceKey: AnyStorageKey public let sourceKey: AnyStorageKey
public init(destinationKey: StorageKey<Value>, sourceKey: AnyStorageKey) { public init(destinationKey: Destination, sourceKey: AnyStorageKey) {
self.destinationKey = destinationKey self.destinationKey = destinationKey
self.sourceKey = sourceKey self.sourceKey = sourceKey
} }
@ -308,12 +290,13 @@ public struct SimpleLegacyMigration<Value: Codable & Sendable>: 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<Value: Codable & Sendable>: ConditionalMigration { public struct AppVersionConditionalMigration<Destination: StorageKey>: ConditionalMigration {
public let destinationKey: StorageKey<Value> public typealias DestinationKey = Destination
public let destinationKey: Destination
public let minAppVersion: String public let minAppVersion: String
public let fallbackMigration: AnyStorageMigration public let fallbackMigration: AnyStorageMigration
public init(destinationKey: StorageKey<Value>, minAppVersion: String, fallbackMigration: AnyStorageMigration) { public init(destinationKey: Destination, minAppVersion: String, fallbackMigration: AnyStorageMigration) {
self.destinationKey = destinationKey self.destinationKey = destinationKey
self.minAppVersion = minAppVersion self.minAppVersion = minAppVersion
self.fallbackMigration = fallbackMigration self.fallbackMigration = fallbackMigration
@ -336,21 +319,24 @@ public struct AppVersionConditionalMigration<Value: Codable & Sendable>: Conditi
```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 SourceValue: Codable & Sendable associatedtype SourceKey: StorageKey
var sourceKey: StorageKey<SourceValue> { get } var sourceKey: SourceKey { get }
func transform(_ source: SourceValue) async throws -> Value func transform(_ source: SourceKey.Value) async throws -> DestinationKey.Value
} }
public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, DestinationValue: Codable & Sendable>: TransformingMigration { public struct DefaultTransformingMigration<Source: StorageKey, Destination: StorageKey>: TransformingMigration {
public let destinationKey: StorageKey<DestinationValue> public typealias SourceKey = Source
public let sourceKey: StorageKey<SourceValue> public typealias DestinationKey = Destination
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: StorageKey<DestinationValue>, destinationKey: Destination,
sourceKey: StorageKey<SourceValue>, sourceKey: Source,
transform: @escaping (SourceValue) async throws -> DestinationValue transform: @escaping (Source.Value) async throws -> Destination.Value
) { ) {
self.destinationKey = destinationKey self.destinationKey = destinationKey
self.sourceKey = sourceKey self.sourceKey = sourceKey
@ -398,18 +384,20 @@ public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, Dest
/// 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 -> Value func aggregate(_ sources: [AnyCodable]) async throws -> DestinationKey.Value
} }
public struct DefaultAggregatingMigration<Value: Codable & Sendable>: AggregatingMigration { public struct DefaultAggregatingMigration<Destination: StorageKey>: AggregatingMigration {
public let destinationKey: StorageKey<Value> public typealias DestinationKey = Destination
public let destinationKey: Destination
public let sourceKeys: [AnyStorageKey] public let sourceKeys: [AnyStorageKey]
public let aggregate: ([AnyCodable]) async throws -> Value public let aggregate: ([AnyCodable]) async throws -> Destination.Value
public init( public init(
destinationKey: StorageKey<Value>, destinationKey: Destination,
sourceKeys: [AnyStorageKey], sourceKeys: [AnyStorageKey],
aggregate: @escaping ([AnyCodable]) async throws -> Value aggregate: @escaping ([AnyCodable]) async throws -> Destination.Value
) { ) {
self.destinationKey = destinationKey self.destinationKey = destinationKey
self.sourceKeys = sourceKeys self.sourceKeys = sourceKeys
@ -536,7 +524,7 @@ internal func recordMigration(for descriptor: StorageKeyDescriptor) {
**Enhanced `get` method:** **Enhanced `get` method:**
```swift ```swift
public func get<Value>(_ key: StorageKey<Value>) async throws -> Value { public func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value {
try validateCatalogRegistration(for: key) try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
@ -582,12 +570,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<Value>(for key: StorageKey<Value>) async -> Date? { public func migrationHistory<Key: StorageKey>(for key: Key) 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<Value>(for key: StorageKey<Value>) async throws -> MigrationResult { public func forceMigration<Key: StorageKey>(for key: Key) async throws -> MigrationResult {
guard let migration = key.migration else { guard let migration = key.migration else {
throw MigrationError.sourceDataNotFound throw MigrationError.sourceDataNotFound
} }
@ -883,32 +871,39 @@ struct UnifiedSettings: Codable {
**`ConditionalMigrationKeys.swift`** **`ConditionalMigrationKeys.swift`**
```swift ```swift
extension StorageKey where Value == UserProfile { extension StorageKeys {
static let legacyConditionalProfile = StorageKey( struct LegacyConditionalProfileKey: StorageKey {
name: "legacy_conditional_profile", typealias Value = UserProfile
domain: .userDefaults(suite: nil),
security: .none, let name = "legacy_conditional_profile"
serializer: .json, let domain: StorageDomain = .userDefaults(suite: nil)
owner: "MigrationDemo", let security: SecurityPolicy = .none
description: "Legacy profile for conditional migration demo.", let serializer: Serializer<UserProfile> = .json
availability: .all, let owner = "MigrationDemo"
syncPolicy: .never let description = "Legacy profile for conditional migration demo."
) let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
static let modernConditionalProfile = StorageKey( }
name: "modern_conditional_profile",
domain: .keychain(service: "com.mbrucedogs.securestorage"), struct ModernConditionalProfileKey: StorageKey {
security: .keychain( 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, let serializer: Serializer<UserProfile> = .json
owner: "MigrationDemo", let owner = "MigrationDemo"
description: "Modern profile for conditional migration demo.", let description = "Modern profile for conditional migration demo."
availability: .all, let availability: PlatformAvailability = .all
syncPolicy: .never, let syncPolicy: SyncPolicy = .never
migration: { _ in AnyStorageMigration(ConditionalProfileMigration()) }
) var migration: AnyStorageMigration? {
AnyStorageMigration(ConditionalProfileMigration())
}
}
} }
``` ```
@ -954,9 +949,9 @@ The `StorageMigration` protocol provides the foundation for all migration types:
```swift ```swift
public protocol StorageMigration: Sendable { public protocol StorageMigration: Sendable {
associatedtype Value: Codable & Sendable associatedtype DestinationKey: StorageKey
var destinationKey: StorageKey<Value> { get } var destinationKey: DestinationKey { 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
} }
@ -1202,25 +1197,27 @@ conditional behavior.
### 1. Define Keys ### 1. Define Keys
Extend `StorageKey` with typed static keys: Extend `StorageKeys` with your own key types:
```swift ```swift
import LocalData import LocalData
extension StorageKey where Value == String { extension StorageKeys {
static let userToken = StorageKey( struct UserTokenKey: StorageKey {
name: "user_token", typealias Value = String
domain: .keychain(service: "com.myapp"),
security: .keychain( let name = "user_token"
let domain: StorageDomain = .keychain(service: "com.myapp")
let security: SecurityPolicy = .keychain(
accessibility: .afterFirstUnlock, accessibility: .afterFirstUnlock,
accessControl: .biometryAny accessControl: .biometryAny
), )
serializer: .json, let serializer: Serializer<String> = .json
owner: "AuthService", let owner = "AuthService"
description: "Stores the current user auth token.", let description = "Stores the current user auth token."
availability: .phoneOnly, let availability: PlatformAvailability = .phoneOnly
syncPolicy: .never let syncPolicy: SyncPolicy = .never
) }
} }
``` ```
@ -1268,7 +1265,7 @@ extension UserTokenKey {
```swift ```swift
// Save // Save
let key = StorageKey.userToken let key = StorageKeys.UserTokenKey()
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)
@ -1282,7 +1279,7 @@ try await StorageRouter.shared.remove(key)
```swift ```swift
// Force migration // Force migration
let result = try await StorageRouter.shared.forceMigration(for: StorageKey.userToken) let result = try await StorageRouter.shared.forceMigration(for: UserTokenKey())
``` ```
``` ```
@ -1383,8 +1380,10 @@ struct MigrationIntegrationTests {
#### `Tests/LocalDataTests/Mocks/MockMigration.swift` #### `Tests/LocalDataTests/Mocks/MockMigration.swift`
```swift ```swift
/// Mock migration for testing /// Mock migration for testing
struct MockMigration<Value: Codable & Sendable>: StorageMigration { struct MockMigration<Destination: StorageKey>: StorageMigration {
let destinationKey: StorageKey<Value> typealias DestinationKey = Destination
let destinationKey: Destination
let shouldSucceed: Bool let shouldSucceed: Bool
let shouldMigrateResult: Bool let shouldMigrateResult: Bool
let migrationDelay: TimeInterval let migrationDelay: TimeInterval
@ -1409,8 +1408,10 @@ struct MockMigration<Value: Codable & Sendable>: StorageMigration {
} }
/// Failing migration for error testing /// Failing migration for error testing
struct FailingMigration<Value: Codable & Sendable>: StorageMigration { struct FailingMigration<Destination: StorageKey>: StorageMigration {
let destinationKey: StorageKey<Value> typealias DestinationKey = Destination
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 }
@ -1546,7 +1547,7 @@ public enum MigrationUtils {
## Migration Checklist ## Migration Checklist
- [x] Create core protocol files - [x] Create core protocol files
- [x] Update StorageKey type with migration - [x] Update StorageKey protocol 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

@ -17,8 +17,8 @@ Create a single, typed, discoverable namespace for persisted app data with consi
## Architecture ## Architecture
### Core Components ### Core Components
- **StorageKey** (generic struct) - Defines storage configuration for each data type - **StorageKey** protocol - Defines storage configuration for each data type
- **StorageKey.migration** - Optional migration attached to a key - **StorageKey.migration** - Optional protocol-based migration attached to a key
- **StorageRouter** actor - Main entry point coordinating all storage operations - **StorageRouter** actor - Main entry point coordinating all storage operations
- **StorageProviding** protocol - Abstraction for storage operations - **StorageProviding** protocol - Abstraction for storage operations
- **StorageKeyCatalog** protocol - Catalog of keys for auditing/validation - **StorageKeyCatalog** protocol - Catalog of keys for auditing/validation
@ -54,7 +54,7 @@ Each helper is a dedicated actor providing thread-safe access to a specific stor
- **DeviceInfo / SystemInfo** - Device and system metrics used by migrations - **DeviceInfo / SystemInfo** - Device and system metrics used by migrations
## Usage Pattern ## Usage Pattern
Apps extend `StorageKey` with typed static keys (e.g., `extension StorageKey where Value == String`) and use `StorageRouter.shared`. This follows the Notification.Name pattern for discoverable keys while preserving value-type inference. Apps extend StorageKeys with their own key types and use StorageRouter.shared. This follows the Notification.Name pattern for discoverable keys.
## Audit & Validation ## Audit & Validation
Apps can register multiple `StorageKeyCatalog`s (e.g., one per module) to generate audit reports and enforce key registration. Registration is additive and validates: Apps can register multiple `StorageKeyCatalog`s (e.g., one per module) to generate audit reports and enforce key registration. Registration is additive and validates:

101
README.md
View File

@ -72,6 +72,7 @@ flowchart TD
## What Ships in the Package ## What Ships in the Package
### Protocols ### Protocols
- **StorageKey** - Define storage configuration for each data type
- **StorageProviding** - Abstraction for storage operations - **StorageProviding** - Abstraction for storage operations
- **KeyMaterialProviding** - Supplies external key material for encryption - **KeyMaterialProviding** - Supplies external key material for encryption
- **StorageMigration** - Protocol-based migration workflows - **StorageMigration** - Protocol-based migration workflows
@ -109,7 +110,6 @@ These are used at app lifecycle start to tune library engine behaviors:
- **KeychainAccessControl** - All 6 access control options (biometry, passcode, etc.) - **KeychainAccessControl** - All 6 access control options (biometry, passcode, etc.)
- **FileDirectory** - documents, caches, custom URL - **FileDirectory** - documents, caches, custom URL
- **StorageError** - Comprehensive error types - **StorageError** - Comprehensive error types
- **StorageKey** - Typed storage configuration (generic over Value)
- **StorageKeyDescriptor** - Audit snapshot of a keys storage metadata - **StorageKeyDescriptor** - Audit snapshot of a keys storage metadata
- **AnyStorageKey** - Type-erased storage key for catalogs - **AnyStorageKey** - Type-erased storage key for catalogs
- **AnyCodable** - Type-erased Codable for mixed-type payloads - **AnyCodable** - Type-erased Codable for mixed-type payloads
@ -126,25 +126,27 @@ These are used at app lifecycle start to tune library engine behaviors:
## Usage ## Usage
### 1. Define Keys ### 1. Define Keys
Extend `StorageKey` with typed static keys: Extend `StorageKeys` with your own key types:
```swift ```swift
import LocalData import LocalData
extension StorageKey where Value == String { extension StorageKeys {
static let userToken = StorageKey( struct UserTokenKey: StorageKey {
name: "user_token", typealias Value = String
domain: .keychain(service: "com.myapp"),
security: .keychain( let name = "user_token"
let domain: StorageDomain = .keychain(service: "com.myapp")
let security: SecurityPolicy = .keychain(
accessibility: .afterFirstUnlock, accessibility: .afterFirstUnlock,
accessControl: .biometryAny accessControl: .biometryAny
), )
serializer: .json, let serializer: Serializer<String> = .json
owner: "AuthService", let owner = "AuthService"
description: "Stores the current user auth token.", let description = "Stores the current user auth token."
availability: .phoneOnly, let availability: PlatformAvailability = .phoneOnly
syncPolicy: .never let syncPolicy: SyncPolicy = .never
) }
} }
``` ```
If you omit `security`, it defaults to `SecurityPolicy.recommended`. If you omit `security`, it defaults to `SecurityPolicy.recommended`.
@ -152,7 +154,7 @@ If you omit `security`, it defaults to `SecurityPolicy.recommended`.
### 2. Use StorageRouter ### 2. Use StorageRouter
```swift ```swift
// Save // Save
let key = StorageKey.userToken let key = StorageKeys.UserTokenKey()
try await StorageRouter.shared.set("token123", for: key) try await StorageRouter.shared.set("token123", for: key)
// Retrieve // Retrieve
@ -172,13 +174,13 @@ struct UserProfile: Codable {
let settings: [String: String] let settings: [String: String]
} }
extension StorageKey where Value == UserProfile { extension StorageKeys {
static let profile = StorageKey( struct ProfileKey: StorageKey {
name: "user_profile", typealias Value = UserProfile // Library handles serialization
domain: .fileSystem(directory: .documents), let name = "user_profile"
owner: "ProfileService", let domain: StorageDomain = .fileSystem(directory: .documents)
description: "Stores the current user profile." // ... other properties
) }
} }
// ... other properties // ... other properties
@ -197,30 +199,19 @@ When you define a `migration` on a key, `StorageRouter.get(key)` will automatica
- It is returned to the caller. - It is returned to the caller.
```swift ```swift
extension StorageKey where Value == String { extension StorageKeys {
static let legacyToken = StorageKey( struct ModernKey: StorageKey {
name: "legacy_token", typealias Value = String
domain: .userDefaults(suite: nil), // ... other properties
security: .none, var migration: AnyStorageMigration? {
serializer: .json,
owner: "AuthService",
description: "Legacy token stored in UserDefaults."
)
static let modernToken = StorageKey(
name: "modern_token",
domain: .keychain(service: "com.myapp"),
owner: "AuthService",
description: "Stores the current user auth token.",
migration: { key in
AnyStorageMigration( AnyStorageMigration(
SimpleLegacyMigration( SimpleLegacyMigration(
destinationKey: key, destinationKey: self,
sourceKey: .key(StorageKey.legacyToken) sourceKey: .key(LegacyKey())
) )
) )
} }
) }
} }
``` ```
@ -229,10 +220,10 @@ For complex migrations, implement `StorageMigration` and attach it to the key.
```swift ```swift
struct TokenMigration: StorageMigration { struct TokenMigration: StorageMigration {
typealias Value = String typealias DestinationKey = StorageKeys.UserTokenKey
let destinationKey = StorageKey.userToken let destinationKey = StorageKeys.UserTokenKey()
let legacyKey = StorageKey.legacyToken let legacyKey = StorageKeys.LegacyTokenKey()
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool { func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
try await router.exists(legacyKey) try await router.exists(legacyKey)
@ -246,14 +237,10 @@ struct TokenMigration: StorageMigration {
} }
} }
extension StorageKey where Value == String { extension StorageKeys.UserTokenKey {
static let userToken = StorageKey( var migration: AnyStorageMigration? {
name: "user_token", AnyStorageMigration(TokenMigration())
domain: .keychain(service: "com.myapp"), }
owner: "AuthService",
description: "Stores the current user auth token.",
migration: { _ in AnyStorageMigration(TokenMigration()) }
)
} }
``` ```
@ -262,7 +249,7 @@ To ensure no "ghost data" remains in legacy keys (e.g., if a bug causes old code
#### Manual Call #### Manual Call
```swift ```swift
try await StorageRouter.shared.forceMigration(for: StorageKey.modernToken) try await StorageRouter.shared.forceMigration(for: StorageKeys.ModernKey())
``` ```
#### Automated Startup Sweep #### Automated Startup Sweep
@ -281,7 +268,7 @@ try await StorageRouter.shared.registerCatalog(ProfileCatalog())
## Storage Design Philosophy ## Storage Design Philosophy
This app intentionally uses a **Type-Safe Storage Design**. Unlike standard iOS development which uses string keys (e.g., `UserDefaults.standard.string(forKey: "user_name")`), this library requires you to define `StorageKey` values. This app intentionally uses a **Type-Safe Storage Design**. Unlike standard iOS development which uses string keys (e.g., `UserDefaults.standard.string(forKey: "user_name")`), this library requires you to define a `StorageKey` type.
### Why types instead of strings? ### Why types instead of strings?
1. **Safety**: The compiler prevents typos. You can't accidentally load from `"user_name"` and save to `"username"`. 1. **Safety**: The compiler prevents typos. You can't accidentally load from `"user_name"` and save to `"username"`.
@ -466,15 +453,15 @@ LocalData can generate a catalog of all configured storage keys, even if no data
### Why `AnyStorageKey`? ### Why `AnyStorageKey`?
`StorageKey` is generic over `Value`, which means you cannot store different key value types in a single array using `[StorageKey]`. Swift requires type erasure for heterogeneous key values, so the catalog uses `[AnyStorageKey]` and builds descriptors behind the scenes. `StorageKey` has an associated type (`Value`), which means you cannot store different keys in a single array using `[StorageKey]` or `some StorageKey`. Swift requires type erasure for heterogeneous protocol values, so the catalog uses `[AnyStorageKey]` and builds descriptors behind the scenes.
1) Define a catalog in your app that lists all keys: 1) Define a catalog in your app that lists all keys:
```swift ```swift
struct AppStorageCatalog: StorageKeyCatalog { struct AppStorageCatalog: StorageKeyCatalog {
let allKeys: [AnyStorageKey] = [ let allKeys: [AnyStorageKey] = [
.key(StorageKey.appVersion), .key(StorageKeys.AppVersionKey()),
.key(StorageKey.userPreferences) .key(StorageKeys.UserPreferencesKey())
] ]
} }
``` ```

View File

@ -0,0 +1,5 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
public enum StorageKeys {
}

View File

@ -1,13 +1,15 @@
import Foundation import Foundation
/// Conditional migration for app version-based migration. /// Conditional migration for app version-based migration.
public struct AppVersionConditionalMigration<Value: Codable & Sendable>: ConditionalMigration { public struct AppVersionConditionalMigration<Destination: StorageKey>: ConditionalMigration {
public let destinationKey: StorageKey<Value> public typealias DestinationKey = Destination
public let destinationKey: Destination
public let minAppVersion: String public let minAppVersion: String
public let fallbackMigration: AnyStorageMigration public let fallbackMigration: AnyStorageMigration
public init( public init(
destinationKey: StorageKey<Value>, destinationKey: Destination,
minAppVersion: String, minAppVersion: String,
fallbackMigration: AnyStorageMigration fallbackMigration: AnyStorageMigration
) { ) {

View File

@ -1,21 +1,23 @@
import Foundation import Foundation
public struct DefaultAggregatingMigration<Value: Codable & Sendable>: AggregatingMigration { public struct DefaultAggregatingMigration<Destination: StorageKey>: AggregatingMigration {
public let destinationKey: StorageKey<Value> public typealias DestinationKey = Destination
public let destinationKey: Destination
public let sourceKeys: [AnyStorageKey] public let sourceKeys: [AnyStorageKey]
public let aggregateAction: ([AnyCodable]) async throws -> Value public let aggregateAction: ([AnyCodable]) async throws -> Destination.Value
public init( public init(
destinationKey: StorageKey<Value>, destinationKey: Destination,
sourceKeys: [AnyStorageKey], sourceKeys: [AnyStorageKey],
aggregate: @escaping ([AnyCodable]) async throws -> Value aggregate: @escaping ([AnyCodable]) async throws -> Destination.Value
) { ) {
self.destinationKey = destinationKey self.destinationKey = destinationKey
self.sourceKeys = sourceKeys self.sourceKeys = sourceKeys
self.aggregateAction = aggregate self.aggregateAction = aggregate
} }
public func aggregate(_ sources: [AnyCodable]) async throws -> Value { public func aggregate(_ sources: [AnyCodable]) async throws -> Destination.Value {
try await aggregateAction(sources) try await aggregateAction(sources)
} }

View File

@ -1,21 +1,24 @@
import Foundation import Foundation
public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, DestinationValue: Codable & Sendable>: TransformingMigration { public struct DefaultTransformingMigration<Source: StorageKey, Destination: StorageKey>: TransformingMigration {
public let destinationKey: StorageKey<DestinationValue> public typealias SourceKey = Source
public let sourceKey: StorageKey<SourceValue> public typealias DestinationKey = Destination
public let transformAction: (SourceValue) async throws -> DestinationValue
public let destinationKey: Destination
public let sourceKey: Source
public let transformAction: (Source.Value) async throws -> Destination.Value
public init( public init(
destinationKey: StorageKey<DestinationValue>, destinationKey: Destination,
sourceKey: StorageKey<SourceValue>, sourceKey: Source,
transform: @escaping (SourceValue) async throws -> DestinationValue transform: @escaping (Source.Value) async throws -> Destination.Value
) { ) {
self.destinationKey = destinationKey self.destinationKey = destinationKey
self.sourceKey = sourceKey self.sourceKey = sourceKey
self.transformAction = transform self.transformAction = transform
} }
public func transform(_ source: SourceValue) async throws -> DestinationValue { public func transform(_ source: Source.Value) async throws -> Destination.Value {
try await transformAction(source) try await transformAction(source)
} }

View File

@ -1,11 +1,13 @@
import Foundation import Foundation
/// Simple 1:1 legacy migration. /// Simple 1:1 legacy migration.
public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration { public struct SimpleLegacyMigration<Destination: StorageKey>: StorageMigration {
public let destinationKey: StorageKey<Value> public typealias DestinationKey = Destination
public let destinationKey: Destination
public let sourceKey: AnyStorageKey public let sourceKey: AnyStorageKey
public init(destinationKey: StorageKey<Value>, sourceKey: AnyStorageKey) { public init(destinationKey: Destination, sourceKey: AnyStorageKey) {
self.destinationKey = destinationKey self.destinationKey = destinationKey
self.sourceKey = sourceKey self.sourceKey = sourceKey
} }

View File

@ -3,7 +3,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<Value>(_ key: StorageKey<Value>) { public init<Key: StorageKey>(_ key: Key) {
self.descriptor = .from(key) self.descriptor = .from(key)
self.migration = key.migration self.migration = key.migration
self.migrateAction = { router in self.migrateAction = { router in
@ -21,7 +21,7 @@ public struct AnyStorageKey: Sendable {
self.migrateAction = migrateAction self.migrateAction = migrateAction
} }
public static func key<Value>(_ key: StorageKey<Value>) -> AnyStorageKey { public static func key<Key: StorageKey>(_ key: Key) -> AnyStorageKey {
AnyStorageKey(key) AnyStorageKey(key)
} }

View File

@ -1,40 +0,0 @@
import Foundation
public struct StorageKey<Value: Codable & Sendable>: Sendable, CustomStringConvertible {
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
private let migrationBuilder: (@Sendable (StorageKey<Value>) -> AnyStorageMigration?)?
public init(
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
}
public var migration: AnyStorageMigration? {
migrationBuilder?(self)
}
}

View File

@ -36,8 +36,8 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
self.catalog = catalog self.catalog = catalog
} }
public static func from<Value>( public static func from<Key: StorageKey>(
_ key: StorageKey<Value>, _ key: Key,
catalog: String? = nil catalog: String? = nil
) -> StorageKeyDescriptor { ) -> StorageKeyDescriptor {
StorageKeyDescriptor( StorageKeyDescriptor(
@ -45,7 +45,7 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
domain: key.domain, domain: key.domain,
security: key.security, security: key.security,
serializer: key.serializer.name, serializer: key.serializer.name,
valueType: String(describing: Value.self), valueType: String(describing: Key.Value.self),
owner: key.owner, owner: key.owner,
availability: key.availability, availability: key.availability,
syncPolicy: key.syncPolicy, syncPolicy: key.syncPolicy,

View File

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

View File

@ -0,0 +1,3 @@
public extension StorageKey {
var security: SecurityPolicy { .recommended }
}

View File

@ -0,0 +1,20 @@
import Foundation
public protocol StorageKey: Sendable, CustomStringConvertible {
associatedtype Value: Codable & Sendable
var name: String { get }
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 }
var migration: AnyStorageMigration? { get }
}
extension StorageKey {
public var migration: AnyStorageMigration? { nil }
}

View File

@ -2,10 +2,10 @@ import Foundation
/// Core migration protocol with high-level methods. /// Core migration protocol with high-level methods.
public protocol StorageMigration: Sendable { public protocol StorageMigration: Sendable {
associatedtype Value: Codable & Sendable associatedtype DestinationKey: StorageKey
/// The destination storage key where migrated data will be stored. /// The destination storage key where migrated data will be stored.
var destinationKey: StorageKey<Value> { get } var destinationKey: DestinationKey { 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

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
public protocol StorageProviding: Sendable { public protocol StorageProviding: Sendable {
func set<Value>(_ value: Value, for key: StorageKey<Value>) async throws func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws
func get<Value>(_ key: StorageKey<Value>) async throws -> Value func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value
func remove<Value>(_ key: StorageKey<Value>) async throws func remove<Key: StorageKey>(_ key: Key) async throws
} }

View File

@ -2,8 +2,8 @@ import Foundation
/// 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 SourceValue: Codable & Sendable associatedtype SourceKey: StorageKey
var sourceKey: StorageKey<SourceValue> { get } var sourceKey: SourceKey { get }
func transform(_ source: SourceValue) async throws -> Value func transform(_ source: SourceKey.Value) async throws -> DestinationKey.Value
} }

View File

@ -127,7 +127,7 @@ public actor StorageRouter: StorageProviding {
} }
/// Returns the last migration date for a specific key, if available. /// Returns the last migration date for a specific key, if available.
public func migrationHistory<Value>(for key: StorageKey<Value>) -> Date? { public func migrationHistory<Key: StorageKey>(for key: Key) -> Date? {
migrationHistory[key.name] migrationHistory[key.name]
} }
@ -138,7 +138,7 @@ public actor StorageRouter: StorageProviding {
/// - value: The value to store. /// - value: The value to store.
/// - key: The storage key defining where and how to store. /// - key: The storage key defining where and how to store.
/// - Throws: Various errors depending on the storage domain and security policy. /// - Throws: Various errors depending on the storage domain and security policy.
public func set<Value>(_ value: Value, for key: StorageKey<Value>) async throws { public func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws {
Logger.debug(">>> [STORAGE] SET: \(key.name) [Domain: \(key.domain)]") Logger.debug(">>> [STORAGE] SET: \(key.name) [Domain: \(key.domain)]")
try validateCatalogRegistration(for: key) try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
@ -155,7 +155,7 @@ public actor StorageRouter: StorageProviding {
/// - Parameter key: The storage key to retrieve. /// - Parameter key: The storage key to retrieve.
/// - Returns: The stored value. /// - Returns: The stored value.
/// - Throws: `StorageError.notFound` if no value exists, plus domain-specific errors. /// - Throws: `StorageError.notFound` if no value exists, plus domain-specific errors.
public func get<Value>(_ key: StorageKey<Value>) async throws -> Value { public func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value {
Logger.debug(">>> [STORAGE] GET: \(key.name)") Logger.debug(">>> [STORAGE] GET: \(key.name)")
try validateCatalogRegistration(for: key) try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
@ -182,7 +182,7 @@ public actor StorageRouter: StorageProviding {
/// - Parameter key: The storage key to migrate. /// - Parameter key: The storage key to migrate.
/// - Returns: The migration result. /// - Returns: The migration result.
/// - Throws: Migration or storage errors. /// - Throws: Migration or storage errors.
public func forceMigration<Value>(for key: StorageKey<Value>) async throws -> MigrationResult { public func forceMigration<Key: StorageKey>(for key: Key) async throws -> MigrationResult {
Logger.debug(">>> [STORAGE] MANUAL MIGRATION: \(key.name)") Logger.debug(">>> [STORAGE] MANUAL MIGRATION: \(key.name)")
try validateCatalogRegistration(for: key) try validateCatalogRegistration(for: key)
@ -207,7 +207,7 @@ public actor StorageRouter: StorageProviding {
/// Removes the value for the given key. /// Removes the value for the given key.
/// - Parameter key: The storage key to remove. /// - Parameter key: The storage key to remove.
/// - Throws: Domain-specific errors if removal fails. /// - Throws: Domain-specific errors if removal fails.
public func remove<Value>(_ key: StorageKey<Value>) async throws { public func remove<Key: StorageKey>(_ key: Key) async throws {
try validateCatalogRegistration(for: key) try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
try await delete(for: .from(key)) try await delete(for: .from(key))
@ -216,7 +216,7 @@ public actor StorageRouter: StorageProviding {
/// Checks if a value exists for the given key. /// Checks if a value exists for the given key.
/// - Parameter key: The storage key to check. /// - Parameter key: The storage key to check.
/// - Returns: True if a value exists. /// - Returns: True if a value exists.
public func exists<Value>(_ key: StorageKey<Value>) async throws -> Bool { public func exists<Key: StorageKey>(_ key: Key) async throws -> Bool {
try validateCatalogRegistration(for: key) try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key) try validatePlatformAvailability(for: key)
@ -265,7 +265,7 @@ public actor StorageRouter: StorageProviding {
// MARK: - Platform Validation // MARK: - Platform Validation
nonisolated private func validatePlatformAvailability<Value>(for key: StorageKey<Value>) throws { nonisolated private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws {
#if os(watchOS) #if os(watchOS)
if key.availability == .phoneOnly { if key.availability == .phoneOnly {
throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name) throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name)
@ -277,7 +277,7 @@ public actor StorageRouter: StorageProviding {
#endif #endif
} }
private func validateCatalogRegistration<Value>(for key: StorageKey<Value>) throws { private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
guard !registeredKeys.isEmpty else { return } guard !registeredKeys.isEmpty else { return }
guard registeredKeys[key.name] != nil else { guard registeredKeys[key.name] != nil else {
let errorMessage = "UNREGISTERED STORAGE KEY: '\(key.name)' (Owner: \(key.owner)) accessed but not found in any registered catalog. Did you forget to call registerCatalog?" let errorMessage = "UNREGISTERED STORAGE KEY: '\(key.name)' (Owner: \(key.owner)) accessed but not found in any registered catalog. Did you forget to call registerCatalog?"
@ -331,7 +331,7 @@ public actor StorageRouter: StorageProviding {
// MARK: - Migration // MARK: - Migration
private func attemptMigration<Value>(for key: StorageKey<Value>) async throws -> Value? { private func attemptMigration<Key: StorageKey>(for key: Key) async throws -> Key.Value? {
guard let migration = resolveMigration(for: key) else { return nil } guard let migration = resolveMigration(for: key) else { return nil }
let context = buildMigrationContext() let context = buildMigrationContext()
@ -354,7 +354,7 @@ public actor StorageRouter: StorageProviding {
return nil return nil
} }
private func resolveMigration<Value>(for key: StorageKey<Value>) -> AnyStorageMigration? { private func resolveMigration<Key: StorageKey>(for key: Key) -> AnyStorageMigration? {
key.migration key.migration
} }
@ -366,8 +366,8 @@ public actor StorageRouter: StorageProviding {
migrationHistory[descriptor.name] = Date() migrationHistory[descriptor.name] = Date()
} }
internal func shouldAllowMigration<Value>( internal func shouldAllowMigration<Key: StorageKey>(
for key: StorageKey<Value>, for key: Key,
context: MigrationContext context: MigrationContext
) async throws -> Bool { ) async throws -> Bool {
guard key.availability.isAvailable(on: context.deviceInfo.platform) else { guard key.availability.isAvailable(on: context.deviceInfo.platform) else {
@ -440,7 +440,7 @@ public actor StorageRouter: StorageProviding {
// MARK: - Storage Operations // MARK: - Storage Operations
private func store<Value>(_ data: Data, for key: StorageKey<Value>) async throws { private func store(_ data: Data, for key: any StorageKey) async throws {
try await store(data, for: .from(key)) try await store(data, for: .from(key))
} }
@ -544,7 +544,7 @@ public actor StorageRouter: StorageProviding {
// MARK: - Sync // MARK: - Sync
private func handleSync<Value>(_ key: StorageKey<Value>, data: Data) async throws { private func handleSync(_ key: any StorageKey, data: Data) async throws {
try await sync.syncIfNeeded( try await sync.syncIfNeeded(
data: data, data: data,
keyName: key.name, keyName: key.name,