Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-16 21:06:34 -06:00
parent e5bf9550a4
commit 5ed222e423
14 changed files with 88 additions and 80 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

@ -0,0 +1,40 @@
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<Key: StorageKey>( public static func from<Value>(
_ key: Key, _ key: StorageKey<Value>,
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: Key.Value.self), valueType: String(describing: 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 -> DestinationKey.Value func aggregate(_ sources: [AnyCodable]) async throws -> Value
} }

View File

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

View File

@ -1,20 +0,0 @@
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 DestinationKey: StorageKey associatedtype Value: Codable & Sendable
/// The destination storage key where migrated data will be stored. /// The destination storage key where migrated data will be stored.
var destinationKey: DestinationKey { get } var destinationKey: StorageKey<Value> { get }
/// Validate if migration should proceed (conditional logic). /// Validate if migration should proceed (conditional logic).
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
public protocol StorageProviding: Sendable { public protocol StorageProviding: Sendable {
func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws func set<Value>(_ value: Value, for key: StorageKey<Value>) async throws
func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value func get<Value>(_ key: StorageKey<Value>) async throws -> Value
func remove<Key: StorageKey>(_ key: Key) async throws func remove<Value>(_ key: StorageKey<Value>) 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 SourceKey: StorageKey associatedtype SourceValue: Codable & Sendable
var sourceKey: SourceKey { get } var sourceKey: StorageKey<SourceValue> { get }
func transform(_ source: SourceKey.Value) async throws -> DestinationKey.Value func transform(_ source: SourceValue) async throws -> Value
} }

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<Key: StorageKey>(for key: Key) -> Date? { public func migrationHistory<Value>(for key: StorageKey<Value>) -> 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<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws { public func set<Value>(_ value: Value, for key: StorageKey<Value>) 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<Key: StorageKey>(_ key: Key) async throws -> Key.Value { public func get<Value>(_ key: StorageKey<Value>) async throws -> 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<Key: StorageKey>(for key: Key) async throws -> MigrationResult { public func forceMigration<Value>(for key: StorageKey<Value>) 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<Key: StorageKey>(_ key: Key) async throws { public func remove<Value>(_ key: StorageKey<Value>) 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<Key: StorageKey>(_ key: Key) async throws -> Bool { public func exists<Value>(_ key: StorageKey<Value>) 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<Key: StorageKey>(for key: Key) throws { nonisolated private func validatePlatformAvailability<Value>(for key: StorageKey<Value>) 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<Key: StorageKey>(for key: Key) throws { private func validateCatalogRegistration<Value>(for key: StorageKey<Value>) 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<Key: StorageKey>(for key: Key) async throws -> Key.Value? { private func attemptMigration<Value>(for key: StorageKey<Value>) async throws -> 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<Key: StorageKey>(for key: Key) -> AnyStorageMigration? { private func resolveMigration<Value>(for key: StorageKey<Value>) -> AnyStorageMigration? {
key.migration key.migration
} }
@ -366,8 +366,8 @@ public actor StorageRouter: StorageProviding {
migrationHistory[descriptor.name] = Date() migrationHistory[descriptor.name] = Date()
} }
internal func shouldAllowMigration<Key: StorageKey>( internal func shouldAllowMigration<Value>(
for key: Key, for key: StorageKey<Value>,
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(_ data: Data, for key: any StorageKey) async throws { private func store<Value>(_ data: Data, for key: StorageKey<Value>) 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(_ key: any StorageKey, data: Data) async throws { private func handleSync<Value>(_ key: StorageKey<Value>, data: Data) async throws {
try await sync.syncIfNeeded( try await sync.syncIfNeeded(
data: data, data: data,
keyName: key.name, keyName: key.name,