Compare commits
No commits in common. "f6bc816a5d34a8c59cf6628f5a7f3d7e6479f9f3" and "e5bf9550a43eccf6e3eda5daadb076d798eb8aed" have entirely different histories.
f6bc816a5d
...
e5bf9550a4
@ -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).
|
||||||
|
|||||||
@ -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()) }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
101
README.md
@ -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 key’s storage metadata
|
- **StorageKeyDescriptor** - Audit snapshot of a key’s 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())
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
5
Sources/LocalData/LocalData.swift
Normal file
5
Sources/LocalData/LocalData.swift
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
// The Swift Programming Language
|
||||||
|
// https://docs.swift.org/swift-book
|
||||||
|
public enum StorageKeys {
|
||||||
|
|
||||||
|
}
|
||||||
@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
3
Sources/LocalData/Protocols/StorageKey+Defaults.swift
Normal file
3
Sources/LocalData/Protocols/StorageKey+Defaults.swift
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
public extension StorageKey {
|
||||||
|
var security: SecurityPolicy { .recommended }
|
||||||
|
}
|
||||||
20
Sources/LocalData/Protocols/StorageKey.swift
Normal file
20
Sources/LocalData/Protocols/StorageKey.swift
Normal 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 }
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user