Update Audit, Configuration, Migrations (+4 more)

Summary:
- Sources: Audit, Configuration, Migrations, Models, Protocols (+2 more)

Stats:
- 37 files changed, 256 insertions(+), 22 deletions(-)
This commit is contained in:
Matt Bruce 2026-01-17 10:22:07 -06:00
parent 7520d195a2
commit ac486a38bc
37 changed files with 256 additions and 22 deletions

View File

@ -1,23 +1,29 @@
import Foundation import Foundation
/// Renders audit reports for storage key catalogs and registries.
public struct StorageAuditReport: Sendable { public struct StorageAuditReport: Sendable {
/// Returns descriptors for all keys in a catalog.
public static func items(for catalog: some StorageKeyCatalog) -> [StorageKeyDescriptor] { public static func items(for catalog: some StorageKeyCatalog) -> [StorageKeyDescriptor] {
catalog.allKeys.map { $0.descriptor.withCatalog(catalog.name) } catalog.allKeys.map { $0.descriptor.withCatalog(catalog.name) }
} }
/// Renders a text report for a catalog.
public static func renderText(_ catalog: some StorageKeyCatalog) -> String { public static func renderText(_ catalog: some StorageKeyCatalog) -> String {
renderText(items(for: catalog)) renderText(items(for: catalog))
} }
/// Renders a text report for a list of type-erased keys.
public static func renderText(_ entries: [AnyStorageKey]) -> String { public static func renderText(_ entries: [AnyStorageKey]) -> String {
renderText(entries.map(\.descriptor)) renderText(entries.map(\.descriptor))
} }
/// Renders a text report for the global registry on the shared router.
public static func renderGlobalRegistry() async -> String { public static func renderGlobalRegistry() async -> String {
let entries = await StorageRouter.shared.allRegisteredEntries() let entries = await StorageRouter.shared.allRegisteredEntries()
return renderText(entries) return renderText(entries)
} }
/// Renders a text report for the global registry grouped by catalog.
public static func renderGlobalRegistryGrouped() async -> String { public static func renderGlobalRegistryGrouped() async -> String {
let catalogs = await StorageRouter.shared.allRegisteredCatalogs() let catalogs = await StorageRouter.shared.allRegisteredCatalogs()
var reportLines: [String] = [] var reportLines: [String] = []
@ -34,6 +40,7 @@ public struct StorageAuditReport: Sendable {
return reportLines.joined(separator: "\n") return reportLines.joined(separator: "\n")
} }
/// Renders a text report from storage key descriptors.
public static func renderText(_ items: [StorageKeyDescriptor]) -> String { public static func renderText(_ items: [StorageKeyDescriptor]) -> String {
let lines = items.map { item in let lines = items.map { item in
var parts: [String] = [] var parts: [String] = []

View File

@ -1,13 +1,19 @@
import Foundation import Foundation
/// Configuration for the EncryptionHelper. /// Configuration for the encryption system.
public struct EncryptionConfiguration: Sendable { public struct EncryptionConfiguration: Sendable {
/// Keychain service for the master key.
public let masterKeyService: String public let masterKeyService: String
/// Keychain account for the master key.
public let masterKeyAccount: String public let masterKeyAccount: String
/// Master key length in bytes.
public let masterKeyLength: Int public let masterKeyLength: Int
/// Default HKDF info string.
public let defaultHKDFInfo: String public let defaultHKDFInfo: String
/// PBKDF2 iteration count.
public let pbkdf2Iterations: Int public let pbkdf2Iterations: Int
/// Creates an encryption configuration.
public init( public init(
masterKeyService: String = "LocalData", masterKeyService: String = "LocalData",
masterKeyAccount: String = "MasterKey", masterKeyAccount: String = "MasterKey",
@ -22,5 +28,6 @@ public struct EncryptionConfiguration: Sendable {
self.pbkdf2Iterations = pbkdf2Iterations self.pbkdf2Iterations = pbkdf2Iterations
} }
/// Default encryption configuration.
public static let `default` = EncryptionConfiguration() public static let `default` = EncryptionConfiguration()
} }

View File

@ -10,10 +10,12 @@ public struct FileStorageConfiguration: Sendable {
/// Primarily used for testing isolation. /// Primarily used for testing isolation.
public let baseURL: URL? public let baseURL: URL?
/// Creates a file storage configuration.
public init(subDirectory: String? = nil, baseURL: URL? = nil) { public init(subDirectory: String? = nil, baseURL: URL? = nil) {
self.subDirectory = subDirectory self.subDirectory = subDirectory
self.baseURL = baseURL self.baseURL = baseURL
} }
/// Default file storage configuration.
public static let `default` = FileStorageConfiguration() public static let `default` = FileStorageConfiguration()
} }

View File

@ -9,6 +9,7 @@ public struct StorageConfiguration: Sendable {
/// The default App Group identifier to use if none is specified in a StorageKey. /// The default App Group identifier to use if none is specified in a StorageKey.
public let defaultAppGroupIdentifier: String? public let defaultAppGroupIdentifier: String?
/// Creates a configuration with optional defaults.
public init( public init(
defaultKeychainService: String? = nil, defaultKeychainService: String? = nil,
defaultAppGroupIdentifier: String? = nil defaultAppGroupIdentifier: String? = nil
@ -17,5 +18,6 @@ public struct StorageConfiguration: Sendable {
self.defaultAppGroupIdentifier = defaultAppGroupIdentifier self.defaultAppGroupIdentifier = defaultAppGroupIdentifier
} }
/// Default configuration with no predefined identifiers.
public static let `default` = StorageConfiguration() public static let `default` = StorageConfiguration()
} }

View File

@ -5,9 +5,11 @@ public struct SyncConfiguration: Sendable {
/// Maximum data size for automatic sync in bytes. /// Maximum data size for automatic sync in bytes.
public let maxAutoSyncSize: Int public let maxAutoSyncSize: Int
/// Creates a sync configuration.
public init(maxAutoSyncSize: Int = 100_000) { public init(maxAutoSyncSize: Int = 100_000) {
self.maxAutoSyncSize = maxAutoSyncSize self.maxAutoSyncSize = maxAutoSyncSize
} }
/// Default sync configuration.
public static let `default` = SyncConfiguration() public static let `default` = SyncConfiguration()
} }

View File

@ -1,11 +1,15 @@
import Foundation import Foundation
/// Conditional migration for app version-based migration. /// Conditional migration that runs only when the app version is below a threshold.
public struct AppVersionConditionalMigration<Value: Codable & Sendable>: ConditionalMigration { public struct AppVersionConditionalMigration<Value: Codable & Sendable>: ConditionalMigration {
/// Destination key for the migration.
public let destinationKey: StorageKey<Value> public let destinationKey: StorageKey<Value>
/// Minimum app version required to skip this migration.
public let minAppVersion: String public let minAppVersion: String
/// Migration to run when the version condition is met.
public let fallbackMigration: AnyStorageMigration public let fallbackMigration: AnyStorageMigration
/// Creates a version-gated migration.
public init( public init(
destinationKey: StorageKey<Value>, destinationKey: StorageKey<Value>,
minAppVersion: String, minAppVersion: String,

View File

@ -1,10 +1,15 @@
import Foundation import Foundation
/// Default migration that aggregates multiple source values into one destination value.
public struct DefaultAggregatingMigration<Value: Codable & Sendable>: AggregatingMigration { public struct DefaultAggregatingMigration<Value: Codable & Sendable>: AggregatingMigration {
/// Destination key for aggregated data.
public let destinationKey: StorageKey<Value> public let destinationKey: StorageKey<Value>
/// Source keys providing legacy values.
public let sourceKeys: [AnyStorageKey] public let sourceKeys: [AnyStorageKey]
/// Async aggregation closure for source values.
public let aggregateAction: @Sendable ([AnyCodable]) async throws -> Value public let aggregateAction: @Sendable ([AnyCodable]) async throws -> Value
/// Creates an aggregating migration with a custom aggregation closure.
public init( public init(
destinationKey: StorageKey<Value>, destinationKey: StorageKey<Value>,
sourceKeys: [AnyStorageKey], sourceKeys: [AnyStorageKey],

View File

@ -1,10 +1,15 @@
import Foundation import Foundation
/// Default migration that transforms a single source value into a destination value.
public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, DestinationValue: Codable & Sendable>: TransformingMigration { public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, DestinationValue: Codable & Sendable>: TransformingMigration {
/// Destination key for the transformed value.
public let destinationKey: StorageKey<DestinationValue> public let destinationKey: StorageKey<DestinationValue>
/// Source key providing the legacy value.
public let sourceKey: StorageKey<SourceValue> public let sourceKey: StorageKey<SourceValue>
/// Async transform from source to destination.
public let transformAction: @Sendable (SourceValue) async throws -> DestinationValue public let transformAction: @Sendable (SourceValue) async throws -> DestinationValue
/// Creates a transforming migration with a custom transform closure.
public init( public init(
destinationKey: StorageKey<DestinationValue>, destinationKey: StorageKey<DestinationValue>,
sourceKey: StorageKey<SourceValue>, sourceKey: StorageKey<SourceValue>,

View File

@ -1,10 +1,13 @@
import Foundation import Foundation
/// Simple 1:1 legacy migration. /// Simple 1:1 legacy migration from a single source key.
public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration { public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration {
/// Destination key for migrated data.
public let destinationKey: StorageKey<Value> public let destinationKey: StorageKey<Value>
/// Source key providing legacy data.
public let sourceKey: AnyStorageKey public let sourceKey: AnyStorageKey
/// Creates a migration from a legacy key to a destination key.
public init(destinationKey: StorageKey<Value>, sourceKey: AnyStorageKey) { public init(destinationKey: StorageKey<Value>, sourceKey: AnyStorageKey) {
self.destinationKey = destinationKey self.destinationKey = destinationKey
self.sourceKey = sourceKey self.sourceKey = sourceKey

View File

@ -1,8 +1,14 @@
import Foundation import Foundation
/// Type-erased `Codable` wrapper for mixed-type payloads.
///
/// - Important: `AnyCodable` is `@unchecked Sendable` because `Any` cannot be verified by the
/// compiler. Callers should only store values that are safe to pass across concurrency domains.
public struct AnyCodable: Codable, @unchecked Sendable { public struct AnyCodable: Codable, @unchecked Sendable {
/// Underlying value (Bool, Int, Double, String, arrays, or dictionaries).
public let value: Any public let value: Any
/// Wraps a value for encoding or decoding.
public init(_ value: Any) { public init(_ value: Any) {
self.value = value self.value = value
} }

View File

@ -1,8 +1,12 @@
/// Type-erased wrapper around ``StorageKey`` for catalogs and audits.
public struct AnyStorageKey: Sendable { public struct AnyStorageKey: Sendable {
/// Snapshot of key metadata for auditing and storage operations.
public internal(set) var descriptor: StorageKeyDescriptor public internal(set) var descriptor: StorageKeyDescriptor
/// Optional migration associated with the key.
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
/// Creates a type-erased key from a typed ``StorageKey``.
public init<Value>(_ key: StorageKey<Value>) { public init<Value>(_ key: StorageKey<Value>) {
self.descriptor = .from(key) self.descriptor = .from(key)
self.migration = key.migration self.migration = key.migration
@ -21,6 +25,7 @@ public struct AnyStorageKey: Sendable {
self.migrateAction = migrateAction self.migrateAction = migrateAction
} }
/// Convenience factory for creating a type-erased key.
public static func key<Value>(_ key: StorageKey<Value>) -> AnyStorageKey { public static func key<Value>(_ key: StorageKey<Value>) -> AnyStorageKey {
AnyStorageKey(key) AnyStorageKey(key)
} }

View File

@ -1,12 +1,14 @@
import Foundation import Foundation
/// Type-erased wrapper for StorageMigration to match AnyStorageKey patterns. /// Type-erased wrapper for ``StorageMigration`` for use in catalogs and registrations.
public struct AnyStorageMigration: Sendable { public struct AnyStorageMigration: Sendable {
/// Descriptor for the migration destination key.
public let destinationDescriptor: StorageKeyDescriptor public let destinationDescriptor: StorageKeyDescriptor
private let shouldMigrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> Bool private let shouldMigrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> Bool
private let migrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> MigrationResult private let migrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> MigrationResult
/// Creates a type-erased migration from a concrete migration.
public init<M: StorageMigration>(_ migration: M) { public init<M: StorageMigration>(_ migration: M) {
self.destinationDescriptor = .from(migration.destinationKey) self.destinationDescriptor = .from(migration.destinationKey)
self.shouldMigrateAction = { @Sendable router, context in self.shouldMigrateAction = { @Sendable router, context in
@ -17,10 +19,12 @@ public struct AnyStorageMigration: Sendable {
} }
} }
/// Evaluates whether the migration should run for the given context.
public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool { public func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
try await shouldMigrateAction(router, context) try await shouldMigrateAction(router, context)
} }
/// Executes the migration and returns its result.
public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult { public func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult {
try await migrateAction(router, context) try await migrateAction(router, context)
} }

View File

@ -1,7 +1,15 @@
import Foundation import Foundation
/// File system directory for file-based storage.
public enum FileDirectory: Sendable, Hashable { public enum FileDirectory: Sendable, Hashable {
case documents, caches, custom(URL) /// App documents directory.
case documents
/// App caches directory.
case caches
/// Custom directory URL.
case custom(URL)
/// Resolves the directory to a concrete URL.
public func url() -> URL { public func url() -> URL {
switch self { switch self {
case .documents: case .documents:

View File

@ -1,8 +1,11 @@
import Foundation import Foundation
/// Identifier for external key material providers.
public struct KeyMaterialSource: Hashable, Sendable { public struct KeyMaterialSource: Hashable, Sendable {
/// Stable identifier for the provider or key source.
public let id: String public let id: String
/// Creates a new key material source identifier.
public init(id: String) { public init(id: String) {
self.id = id self.id = id
} }

View File

@ -2,12 +2,18 @@ import Foundation
/// Context information available for conditional migrations. /// Context information available for conditional migrations.
public struct MigrationContext: Sendable { public struct MigrationContext: Sendable {
/// Current app version string.
public let appVersion: String public let appVersion: String
/// Device metadata for platform checks.
public let deviceInfo: DeviceInfo public let deviceInfo: DeviceInfo
/// Previously recorded migration timestamps keyed by storage key name.
public let migrationHistory: [String: Date] public let migrationHistory: [String: Date]
/// Caller-provided preferences that may influence migration behavior.
public let userPreferences: [String: AnyCodable] public let userPreferences: [String: AnyCodable]
/// System information for conditional checks.
public let systemInfo: SystemInfo public let systemInfo: SystemInfo
/// Creates a migration context with optional overrides.
public init( public init(
appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown", appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
deviceInfo: DeviceInfo = .current, deviceInfo: DeviceInfo = .current,

View File

@ -2,13 +2,21 @@ import Foundation
/// Migration-specific error types. /// Migration-specific error types.
public enum MigrationError: Error, Sendable, Equatable { public enum MigrationError: Error, Sendable, Equatable {
/// Validation failed before migration could run.
case validationFailed(String) case validationFailed(String)
/// Transformation failed while converting source to destination.
case transformationFailed(String) case transformationFailed(String)
/// Underlying storage error occurred.
case storageFailed(StorageError) case storageFailed(StorageError)
/// Conditional migration criteria were not met.
case conditionalMigrationFailed case conditionalMigrationFailed
/// A migration is already in progress for the key.
case migrationInProgress case migrationInProgress
/// No source data was found to migrate.
case sourceDataNotFound case sourceDataNotFound
/// Source and destination types are incompatible.
case incompatibleTypes(String) case incompatibleTypes(String)
/// Aggregation failed while combining multiple sources.
case aggregationFailed(String) case aggregationFailed(String)
} }

View File

@ -2,12 +2,18 @@ import Foundation
/// Result of a migration operation with detailed information. /// Result of a migration operation with detailed information.
public struct MigrationResult: Sendable { public struct MigrationResult: Sendable {
/// Whether the migration completed successfully.
public let success: Bool public let success: Bool
/// Number of values migrated.
public let migratedCount: Int public let migratedCount: Int
/// Errors captured during migration.
public let errors: [MigrationError] public let errors: [MigrationError]
/// Additional metadata provided by the migration.
public let metadata: [String: AnyCodable] public let metadata: [String: AnyCodable]
/// Duration of the migration in seconds.
public let duration: TimeInterval public let duration: TimeInterval
/// Creates a migration result with optional details.
public init( public init(
success: Bool, success: Bool,
migratedCount: Int = 0, migratedCount: Int = 0,

View File

@ -1,13 +1,19 @@
import Foundation import Foundation
/// Specifies which platforms a storage key is allowed to run on.
public enum PlatformAvailability: Sendable { public enum PlatformAvailability: Sendable {
case all // iPhone + Watch (small only!) /// Available on iOS and watchOS (small data only on watch).
case phoneOnly // iPhone only (large/sensitive) case all
case watchOnly // Watch local only /// Available only on iOS (large or sensitive data).
case phoneWithWatchSync // Small data for explicit sync case phoneOnly
/// Available only on watchOS.
case watchOnly
/// Available on iOS and watchOS with explicit sync behavior.
case phoneWithWatchSync
} }
public extension PlatformAvailability { public extension PlatformAvailability {
/// Returns `true` if the key should be available on the given platform.
func isAvailable(on platform: Platform) -> Bool { func isAvailable(on platform: Platform) -> Bool {
switch self { switch self {
case .all: case .all:

View File

@ -2,26 +2,40 @@ import Foundation
import CryptoKit import CryptoKit
import Security import Security
/// Security policy for a ``StorageKey``.
public enum SecurityPolicy: Equatable, Sendable { public enum SecurityPolicy: Equatable, Sendable {
/// Stores data without additional security.
case none case none
/// Encrypts data before storage using the specified policy.
case encrypted(EncryptionPolicy) case encrypted(EncryptionPolicy)
/// Stores data directly in the Keychain with accessibility and access control options.
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?) case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
/// Recommended security policy for most sensitive data.
public static let recommended: SecurityPolicy = .encrypted(.recommended) public static let recommended: SecurityPolicy = .encrypted(.recommended)
/// Encryption algorithm and key derivation settings.
public enum EncryptionPolicy: Equatable, Sendable { public enum EncryptionPolicy: Equatable, Sendable {
/// AES-256-GCM encryption.
case aes256(keyDerivation: KeyDerivation) case aes256(keyDerivation: KeyDerivation)
/// ChaCha20-Poly1305 encryption.
case chacha20Poly1305(keyDerivation: KeyDerivation) case chacha20Poly1305(keyDerivation: KeyDerivation)
/// External key material with key derivation.
case external(source: KeyMaterialSource, keyDerivation: KeyDerivation) case external(source: KeyMaterialSource, keyDerivation: KeyDerivation)
/// Recommended encryption policy for most cases.
public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf()) public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf())
/// Convenience for external key material with default HKDF.
public static func external(source: KeyMaterialSource) -> EncryptionPolicy { public static func external(source: KeyMaterialSource) -> EncryptionPolicy {
.external(source: source, keyDerivation: .hkdf()) .external(source: source, keyDerivation: .hkdf())
} }
} }
/// Key derivation algorithms for encryption keys.
public enum KeyDerivation: Equatable, Sendable { public enum KeyDerivation: Equatable, Sendable {
/// PBKDF2 with optional iterations and salt.
case pbkdf2(iterations: Int? = nil, salt: Data? = nil) case pbkdf2(iterations: Int? = nil, salt: Data? = nil)
/// HKDF with optional salt and info.
case hkdf(salt: Data? = nil, info: Data? = nil) case hkdf(salt: Data? = nil, info: Data? = nil)
} }
} }

View File

@ -1,10 +1,20 @@
import Foundation import Foundation
/// Encodes and decodes values for storage.
public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConvertible { public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConvertible {
/// Encodes a value into `Data`.
public let encode: @Sendable (Value) throws -> Data public let encode: @Sendable (Value) throws -> Data
/// Decodes a value from `Data`.
public let decode: @Sendable (Data) throws -> Value public let decode: @Sendable (Data) throws -> Value
/// Human-readable serializer name used in audit reports.
public let name: String public let name: String
/// Creates a custom serializer.
///
/// - Parameters:
/// - encode: Encoder for values.
/// - decode: Decoder for values.
/// - name: Display name for audit and logging.
public init( public init(
encode: @escaping @Sendable (Value) throws -> Data, encode: @escaping @Sendable (Value) throws -> Data,
decode: @escaping @Sendable (Data) throws -> Value, decode: @escaping @Sendable (Data) throws -> Value,
@ -15,8 +25,10 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
self.name = name self.name = name
} }
/// Description used by `CustomStringConvertible`.
public var description: String { name } public var description: String { name }
/// JSON serializer using `JSONEncoder` and `JSONDecoder`.
public static var json: Serializer<Value> { public static var json: Serializer<Value> {
Serializer<Value>( Serializer<Value>(
encode: { try JSONEncoder().encode($0) }, encode: { try JSONEncoder().encode($0) },
@ -25,6 +37,7 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
) )
} }
/// Property list serializer using `PropertyListEncoder` and `PropertyListDecoder`.
public static var plist: Serializer<Value> { public static var plist: Serializer<Value> {
Serializer<Value>( Serializer<Value>(
encode: { try PropertyListEncoder().encode($0) }, encode: { try PropertyListEncoder().encode($0) },
@ -33,6 +46,7 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
) )
} }
/// Convenience for custom serializers.
public static func custom( public static func custom(
encode: @escaping @Sendable (Value) throws -> Data, encode: @escaping @Sendable (Value) throws -> Data,
decode: @escaping @Sendable (Data) throws -> Value, decode: @escaping @Sendable (Data) throws -> Value,
@ -43,6 +57,7 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
} }
public extension Serializer where Value == Data { public extension Serializer where Value == Data {
/// Serializer that passes through raw `Data`.
static var data: Serializer<Value> { static var data: Serializer<Value> {
Serializer<Value>(encode: { $0 }, decode: { $0 }, name: "data") Serializer<Value>(encode: { $0 }, decode: { $0 }, name: "data")
} }

View File

@ -1,10 +1,17 @@
import Foundation import Foundation
/// Storage location for a ``StorageKey``.
public enum StorageDomain: Sendable, Equatable { public enum StorageDomain: Sendable, Equatable {
/// Standard `UserDefaults` using the provided suite name.
case userDefaults(suite: String?) case userDefaults(suite: String?)
/// App group `UserDefaults` using the provided group identifier.
case appGroupUserDefaults(identifier: String?) case appGroupUserDefaults(identifier: String?)
/// Keychain storage using the provided service identifier.
case keychain(service: String?) case keychain(service: String?)
/// File system storage in the specified directory.
case fileSystem(directory: FileDirectory) case fileSystem(directory: FileDirectory)
/// Encrypted file system storage in the specified directory.
case encryptedFileSystem(directory: FileDirectory) case encryptedFileSystem(directory: FileDirectory)
/// App group file storage using the group identifier and directory.
case appGroupFileSystem(identifier: String?, directory: FileDirectory) case appGroupFileSystem(identifier: String?, directory: FileDirectory)
} }

View File

@ -1,18 +1,34 @@
import Foundation import Foundation
/// Errors thrown by storage operations and migrations.
public enum StorageError: Error, Equatable { public enum StorageError: Error, Equatable {
case serializationFailed, deserializationFailed /// Failed to encode a value.
case serializationFailed
/// Failed to decode stored data.
case deserializationFailed
/// Failed to apply or remove security for stored data.
case securityApplicationFailed case securityApplicationFailed
/// Underlying Keychain error.
case keychainError(OSStatus) case keychainError(OSStatus)
/// File system error description.
case fileError(String) // Changed from Error to String for easier Equatable conformance case fileError(String) // Changed from Error to String for easier Equatable conformance
/// A phone-only key was accessed on watchOS.
case phoneOnlyKeyAccessedOnWatch(String) case phoneOnlyKeyAccessedOnWatch(String)
/// A watch-only key was accessed on iOS.
case watchOnlyKeyAccessedOnPhone(String) case watchOnlyKeyAccessedOnPhone(String)
/// Invalid UserDefaults suite name.
case invalidUserDefaultsSuite(String) case invalidUserDefaultsSuite(String)
/// Invalid App Group identifier.
case invalidAppGroupIdentifier(String) case invalidAppGroupIdentifier(String)
/// Sync payload exceeded the configured maximum size.
case dataTooLargeForSync case dataTooLargeForSync
/// No value exists for the requested key.
case notFound case notFound
/// The key is not registered in any catalog.
case unregisteredKey(String) case unregisteredKey(String)
/// Duplicate key names detected during registration.
case duplicateRegisteredKeys([String]) case duplicateRegisteredKeys([String])
/// Missing or empty key description.
case missingDescription(String) case missingDescription(String)
public static func == (lhs: StorageError, rhs: StorageError) -> Bool { public static func == (lhs: StorageError, rhs: StorageError) -> Bool {

View File

@ -1,17 +1,47 @@
import Foundation import Foundation
/// Typed descriptor for a single piece of persisted data.
///
/// Use `StorageKey` to define where a value is stored, how it is secured, how it is serialized,
/// and how it participates in sync and migration behaviors.
///
/// - Important: `name` should be unique within its storage domain to avoid collisions.
/// - Note: `Value` must conform to `Codable` and `Sendable`.
public struct StorageKey<Value: Codable & Sendable>: Sendable, CustomStringConvertible { public struct StorageKey<Value: Codable & Sendable>: Sendable, CustomStringConvertible {
/// Unique identifier for the stored value within its domain.
public let name: String public let name: String
/// Storage location for the value (UserDefaults, Keychain, file system, etc.).
public let domain: StorageDomain public let domain: StorageDomain
/// Security policy applied to stored bytes.
public let security: SecurityPolicy public let security: SecurityPolicy
/// Serializer used to convert between `Value` and `Data`.
public let serializer: Serializer<Value> public let serializer: Serializer<Value>
/// Owning feature or module for auditability.
public let owner: String public let owner: String
/// Human-readable description for audit reports.
public let description: String public let description: String
/// Platform availability constraints for reads/writes and migrations.
public let availability: PlatformAvailability public let availability: PlatformAvailability
/// WatchConnectivity sync behavior for this key.
public let syncPolicy: SyncPolicy public let syncPolicy: SyncPolicy
/// Lazily builds a migration using the fully initialized key.
///
/// This avoids capturing `self` during initialization and keeps the destination key consistent.
private let migrationBuilder: (@Sendable (StorageKey<Value>) -> AnyStorageMigration?)? private let migrationBuilder: (@Sendable (StorageKey<Value>) -> AnyStorageMigration?)?
/// Creates a storage key with optional security, serializer, availability, sync, and migration.
///
/// - Parameters:
/// - name: Unique identifier for the stored value.
/// - domain: Storage location for the value.
/// - security: Security policy applied to stored bytes. Defaults to `.recommended`.
/// - serializer: Serializer used to encode/decode values. Defaults to `.json`.
/// - owner: Owning feature or module for auditability.
/// - description: Human-readable description for audit reports.
/// - availability: Platform availability constraints. Defaults to `.all`.
/// - syncPolicy: WatchConnectivity sync behavior. Defaults to `.never`.
/// - migration: Optional builder that creates a migration using this key as destination.
public init( public init(
name: String, name: String,
domain: StorageDomain, domain: StorageDomain,
@ -34,6 +64,9 @@ public struct StorageKey<Value: Codable & Sendable>: Sendable, CustomStringConve
self.migrationBuilder = migration self.migrationBuilder = migration
} }
/// Construct a migration on demand using this key as the destination.
///
/// - Returns: The migration for this key, or `nil` if none is configured.
public var migration: AnyStorageMigration? { public var migration: AnyStorageMigration? {
migrationBuilder?(self) migrationBuilder?(self)
} }

View File

@ -1,15 +1,26 @@
import Foundation import Foundation
/// Snapshot of a ``StorageKey`` used for audit and registration.
public struct StorageKeyDescriptor: Sendable, CustomStringConvertible { public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
/// Key name within its domain.
public let name: String public let name: String
/// Storage domain for the key.
public let domain: StorageDomain public let domain: StorageDomain
/// Security policy applied to the key.
public let security: SecurityPolicy public let security: SecurityPolicy
/// Serializer name used for encoding/decoding.
public let serializer: String public let serializer: String
/// String representation of the value type.
public let valueType: String public let valueType: String
/// Owning module or feature name.
public let owner: String public let owner: String
/// Platform availability for the key.
public let availability: PlatformAvailability public let availability: PlatformAvailability
/// Sync policy for WatchConnectivity.
public let syncPolicy: SyncPolicy public let syncPolicy: SyncPolicy
/// Human-readable description for audit reports.
public let description: String public let description: String
/// Optional catalog name the key belongs to.
public let catalog: String? public let catalog: String?
init( init(
@ -36,6 +47,7 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
self.catalog = catalog self.catalog = catalog
} }
/// Builds a descriptor from a ``StorageKey``.
public static func from<Value>( public static func from<Value>(
_ key: StorageKey<Value>, _ key: StorageKey<Value>,
catalog: String? = nil catalog: String? = nil

View File

@ -1,7 +1,11 @@
import Foundation import Foundation
/// Defines how a key participates in WatchConnectivity sync.
public enum SyncPolicy: Sendable { public enum SyncPolicy: Sendable {
case never // Default for most /// No sync behavior.
case manual // Manual WCSession send case never
case automaticSmall // Auto-sync if small /// Sync only when the app explicitly requests it.
case manual
/// Automatically sync when data size is below the configured threshold.
case automaticSmall
} }

View File

@ -2,6 +2,8 @@ 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 {
/// The set of source keys used to build the destination value.
var sourceKeys: [AnyStorageKey] { get } var sourceKeys: [AnyStorageKey] { get }
/// Aggregates decoded source values into the destination value type.
func aggregate(_ sources: [AnyCodable]) async throws -> Value func aggregate(_ sources: [AnyCodable]) async throws -> Value
} }

View File

@ -1,5 +1,7 @@
import Foundation import Foundation
/// Supplies external key material for encryption policies.
public protocol KeyMaterialProviding: Sendable { public protocol KeyMaterialProviding: Sendable {
/// Returns key material associated with the given key name.
func keyMaterial(for keyName: String) async throws -> Data func keyMaterial(for keyName: String) async throws -> Data
} }

View File

@ -1,8 +1,10 @@
import Foundation import Foundation
/// Protocol defining the interface for Keychain operations. /// Protocol defining the interface for Keychain operations.
/// Allows for dependency injection and mocking in tests. ///
/// Conformers enable dependency injection and mocking in tests.
public protocol KeychainStoring: Sendable { public protocol KeychainStoring: Sendable {
/// Stores data for a keychain entry.
func set( func set(
_ data: Data, _ data: Data,
service: String, service: String,
@ -11,11 +13,15 @@ public protocol KeychainStoring: Sendable {
accessControl: KeychainAccessControl? accessControl: KeychainAccessControl?
) async throws ) async throws
/// Retrieves data for a keychain entry.
func get(service: String, key: String) async throws -> Data? func get(service: String, key: String) async throws -> Data?
/// Deletes a keychain entry.
func delete(service: String, key: String) async throws func delete(service: String, key: String) async throws
/// Checks if a keychain entry exists.
func exists(service: String, key: String) async throws -> Bool func exists(service: String, key: String) async throws -> Bool
/// Deletes all keychain entries for a service.
func deleteAll(service: String) async throws func deleteAll(service: String) async throws
} }

View File

@ -1,9 +1,13 @@
/// Collection of storage keys used for registration and auditing.
public protocol StorageKeyCatalog: Sendable { public protocol StorageKeyCatalog: Sendable {
/// Human-readable catalog name used in audit reports.
var name: String { get } var name: String { get }
/// All keys owned by this catalog.
var allKeys: [AnyStorageKey] { get } var allKeys: [AnyStorageKey] { get }
} }
extension StorageKeyCatalog { extension StorageKeyCatalog {
/// Default catalog name derived from the type name.
public var name: String { public var name: String {
let fullName = String(describing: type(of: self)) let fullName = String(describing: type(of: self))
// Simple cleanup for generic or nested names if needed, // Simple cleanup for generic or nested names if needed,

View File

@ -1,20 +1,22 @@
import Foundation import Foundation
/// Core migration protocol with high-level methods. /// Core migration protocol for moving data into a destination ``StorageKey``.
public protocol StorageMigration: Sendable { public protocol StorageMigration: Sendable {
/// The value type produced by the migration.
associatedtype Value: Codable & Sendable 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: StorageKey<Value> { get } var destinationKey: StorageKey<Value> { get }
/// Validate if migration should proceed (conditional logic). /// Determines whether the migration should run for the given context.
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool
/// Execute the migration process. /// Executes the migration and returns a result describing success or failure.
func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult func migrate(using router: StorageRouter, context: MigrationContext) async throws -> MigrationResult
} }
public extension StorageMigration { public extension StorageMigration {
/// Default conditional behavior that checks platform availability and existing data.
func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool { func shouldMigrate(using router: StorageRouter, context: MigrationContext) async throws -> Bool {
try await router.shouldAllowMigration(for: destinationKey, context: context) try await router.shouldAllowMigration(for: destinationKey, context: context)
} }

View File

@ -1,7 +1,13 @@
import Foundation import Foundation
/// Abstraction for basic storage operations.
///
/// Conforming types persist and retrieve values described by a ``StorageKey``.
public protocol StorageProviding: Sendable { public protocol StorageProviding: Sendable {
/// Stores a value for the given key.
func set<Value>(_ value: Value, for key: StorageKey<Value>) async throws func set<Value>(_ value: Value, for key: StorageKey<Value>) async throws
/// Retrieves a value for the given key.
func get<Value>(_ key: StorageKey<Value>) async throws -> Value func get<Value>(_ key: StorageKey<Value>) async throws -> Value
/// Removes a value for the given key.
func remove<Value>(_ key: StorageKey<Value>) async throws func remove<Value>(_ key: StorageKey<Value>) async throws
} }

View File

@ -1,9 +1,12 @@
import Foundation import Foundation
/// Migration protocol that supports data transformation during migration. /// Migration protocol that transforms a source value into a destination value.
public protocol TransformingMigration: StorageMigration { public protocol TransformingMigration: StorageMigration {
/// The value type stored at the source key.
associatedtype SourceValue: Codable & Sendable associatedtype SourceValue: Codable & Sendable
/// The source key to read from during migration.
var sourceKey: StorageKey<SourceValue> { get } var sourceKey: StorageKey<SourceValue> { get }
/// Transforms a source value into the destination value type.
func transform(_ source: SourceValue) async throws -> Value func transform(_ source: SourceValue) async throws -> Value
} }

View File

@ -4,6 +4,7 @@ import Foundation
/// Uses specialized helper actors for each storage domain. /// Uses specialized helper actors for each storage domain.
public actor StorageRouter: StorageProviding { public actor StorageRouter: StorageProviding {
/// Shared router instance for app-wide storage access.
public static let shared = StorageRouter() public static let shared = StorageRouter()
private var catalogRegistries: [String: [AnyStorageKey]] = [:] private var catalogRegistries: [String: [AnyStorageKey]] = [:]
@ -16,9 +17,10 @@ public actor StorageRouter: StorageProviding {
private let defaults: UserDefaultsHelper private let defaults: UserDefaultsHelper
private let sync: SyncHelper private let sync: SyncHelper
/// Initialize a new StorageRouter. /// Initializes a new router with injected helpers.
/// Internal for testing isolation via @testable import. ///
/// Consumers should use the `shared` singleton. /// - Important: Internal for testing isolation via `@testable import`.
/// Production code should use ``StorageRouter/shared``.
internal init( internal init(
keychain: KeychainStoring = KeychainHelper.shared, keychain: KeychainStoring = KeychainHelper.shared,
encryption: EncryptionHelper = .shared, encryption: EncryptionHelper = .shared,
@ -111,7 +113,7 @@ public actor StorageRouter: StorageProviding {
catalogRegistries catalogRegistries
} }
/// Returns all currently registered storage keys. /// Returns all currently registered storage keys as a flat list.
public func allRegisteredEntries() -> [AnyStorageKey] { public func allRegisteredEntries() -> [AnyStorageKey] {
Array(registeredKeys.values) Array(registeredKeys.values)
} }

View File

@ -9,11 +9,16 @@ import WatchKit
/// Device information for migration context. /// Device information for migration context.
public struct DeviceInfo: Sendable { public struct DeviceInfo: Sendable {
/// Current platform (iOS, watchOS, unknown).
public let platform: Platform public let platform: Platform
/// OS version string.
public let systemVersion: String public let systemVersion: String
/// Device model identifier or marketing name.
public let model: String public let model: String
/// Whether the device is a simulator.
public let isSimulator: Bool public let isSimulator: Bool
/// Current device info derived from the running environment.
public static let current = DeviceInfo() public static let current = DeviceInfo()
private init() { private init() {
@ -34,6 +39,7 @@ public struct DeviceInfo: Sendable {
self.isSimulator = ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil self.isSimulator = ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil
} }
/// Creates a custom device info instance (useful for tests).
public init(platform: Platform, systemVersion: String, model: String, isSimulator: Bool) { public init(platform: Platform, systemVersion: String, model: String, isSimulator: Bool) {
self.platform = platform self.platform = platform
self.systemVersion = systemVersion self.systemVersion = systemVersion

View File

@ -2,15 +2,18 @@ import Foundation
/// Utilities for common migration operations. /// Utilities for common migration operations.
public enum MigrationUtils { public enum MigrationUtils {
/// Returns `true` if a value can be transformed between the given types.
public static func canTransform<T, U>(from: T.Type, to: U.Type) -> Bool { public static func canTransform<T, U>(from: T.Type, to: U.Type) -> Bool {
if T.self is U.Type { return true } if T.self is U.Type { return true }
return T.self == String.self || U.self == String.self return T.self == String.self || U.self == String.self
} }
/// Estimates the size of a data payload in bytes.
public static func estimatedSize(for data: Data) -> UInt64 { public static func estimatedSize(for data: Data) -> UInt64 {
UInt64(data.count) UInt64(data.count)
} }
/// Validates that source and destination descriptors are compatible.
public static func validateCompatibility( public static func validateCompatibility(
source: StorageKeyDescriptor, source: StorageKeyDescriptor,
destination: StorageKeyDescriptor destination: StorageKeyDescriptor

View File

@ -1,7 +1,11 @@
import Foundation import Foundation
/// Supported runtime platforms for storage availability checks.
public enum Platform: String, CaseIterable, Sendable { public enum Platform: String, CaseIterable, Sendable {
/// iOS platform.
case iOS = "iOS" case iOS = "iOS"
/// watchOS platform.
case watchOS = "watchOS" case watchOS = "watchOS"
/// Unknown or unsupported platform.
case unknown = "unknown" case unknown = "unknown"
} }

View File

@ -2,10 +2,14 @@ import Foundation
/// System information for migration context. /// System information for migration context.
public struct SystemInfo: Sendable { public struct SystemInfo: Sendable {
/// Free disk space in bytes.
public let availableDiskSpace: UInt64 public let availableDiskSpace: UInt64
/// Physical memory in bytes.
public let availableMemory: UInt64 public let availableMemory: UInt64
/// Whether Low Power Mode is enabled.
public let isLowPowerModeEnabled: Bool public let isLowPowerModeEnabled: Bool
/// Current system info derived from the running environment.
public static let current = SystemInfo() public static let current = SystemInfo()
private init() { private init() {