LocalData/Sources/LocalData/Services/StorageRouter.swift
Matt Bruce 94d025df32 Update Helpers, Services
Summary:
- Sources: update Helpers, Services

Stats:
- 2 files changed, 90 insertions(+), 2 deletions(-)
2026-01-18 13:43:10 -06:00

668 lines
26 KiB
Swift

import Foundation
/// The main storage router that coordinates all storage operations.
/// Uses specialized helper actors for each storage domain.
public actor StorageRouter: StorageProviding {
public static let shared = StorageRouter()
private var catalogRegistries: [String: [AnyStorageKey]] = [:]
private var registeredKeys: [String: AnyStorageKey] = [:]
private var migrationHistory: [String: Date] = [:]
private var storageConfiguration: StorageConfiguration = .default
private let keychain: KeychainStoring
private let encryption: EncryptionHelper
private let file: FileStorageHelper
private let defaults: UserDefaultsHelper
private let sync: SyncHelper
/// Initialize a new StorageRouter.
/// Internal for testing isolation via @testable import.
/// Consumers should use the `shared` singleton.
internal init(
keychain: KeychainStoring = KeychainHelper.shared,
encryption: EncryptionHelper = .shared,
file: FileStorageHelper = .shared,
defaults: UserDefaultsHelper = .shared,
sync: SyncHelper = .shared
) {
self.keychain = keychain
self.encryption = encryption
self.file = file
self.defaults = defaults
self.sync = sync
}
// MARK: - Configuration
/// Updates the encryption configuration.
/// > [!WARNING]
/// > Changing these constants in an existing app will cause the app to look for the master key
/// > under a new name. Previously encrypted data will be lost.
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
await encryption.updateConfiguration(configuration)
await encryption.updateKeychainHelper(keychain)
}
/// Updates the sync configuration.
public func updateSyncConfiguration(_ configuration: SyncConfiguration) async {
await sync.updateConfiguration(configuration)
}
/// Updates the file storage configuration.
public func updateFileStorageConfiguration(_ configuration: FileStorageConfiguration) async {
await file.updateConfiguration(configuration)
}
/// Updates the global storage configuration (defaults).
public func updateStorageConfiguration(_ configuration: StorageConfiguration) {
self.storageConfiguration = configuration
}
// MARK: - Key Material Providers
/// Registers a key material provider for external encryption policies.
public func registerKeyMaterialProvider(
_ provider: any KeyMaterialProviding,
for source: KeyMaterialSource
) async {
await encryption.updateKeychainHelper(keychain)
await encryption.registerKeyMaterialProvider(provider, for: source)
}
/// Registers a catalog of known storage keys for audit and validation.
/// When registered, all storage operations will verify keys are listed.
/// - Parameters:
/// - catalog: The catalog type to register.
/// - migrateImmediately: If true, triggers a proactive migration (sweep) for all keys in the catalog.
public func registerCatalog(_ catalog: some StorageKeyCatalog, migrateImmediately: Bool = false) async throws {
let entries = catalog.allKeys
try validateDescription(entries)
try validateUniqueKeys(entries)
// Validate against existing registrations to prevent collisions across catalogs
let catalogName = catalog.name
Logger.info(">>> [STORAGE] Registering Catalog: \(catalogName) (\(entries.count) keys)")
for entry in entries {
if let existing = registeredKeys[entry.descriptor.name] {
let existingCatalog = existing.descriptor.catalog ?? "Unknown"
let errorMessage = "STORAGE KEY COLLISION: Key name '\(entry.descriptor.name)' in \(catalogName) is already registered by \(existingCatalog)."
Logger.error(errorMessage)
throw StorageError.duplicateRegisteredKeys([entry.descriptor.name])
}
}
// Add to registry with catalog name context
catalogRegistries[catalogName] = entries
for entry in entries {
registeredKeys[entry.descriptor.name] = entry.withCatalog(catalogName)
}
Logger.info("<<< [STORAGE] Catalog Registered Successfully: \(catalogName)")
if migrateImmediately {
try await migrateAllRegisteredKeys()
}
}
/// Returns all currently registered storage keys, grouped by catalog name.
public func allRegisteredCatalogs() -> [String: [AnyStorageKey]] {
catalogRegistries
}
/// Returns all currently registered storage keys.
public func allRegisteredEntries() -> [AnyStorageKey] {
Array(registeredKeys.values)
}
/// Triggers a proactive migration (sweep) for all registered storage keys.
/// This "drains" any legacy data into the modern storage locations.
public func migrateAllRegisteredKeys() async throws {
Logger.debug(">>> [STORAGE] STARTING GLOBAL MIGRATION SWEEP")
for entry in registeredKeys.values {
try await entry.migrate(on: self)
}
Logger.debug("<<< [STORAGE] GLOBAL MIGRATION SWEEP COMPLETE")
}
/// Returns the last migration date for a specific key, if available.
public func migrationHistory<Key: StorageKey>(for key: Key) -> Date? {
migrationHistory[key.name]
}
// MARK: - StorageProviding Implementation
/// Stores a value for the given key.
/// - Parameters:
/// - value: The value to store.
/// - key: The storage key defining where and how to store.
/// - Throws: Various errors depending on the storage domain and security policy.
public func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws {
Logger.debug(">>> [STORAGE] SET: \(key.name) [Domain: \(key.domain)]")
try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key)
let data = try serialize(value, with: key.serializer)
let securedData = try await applySecurity(data, for: .from(key), isEncrypt: true)
try await store(securedData, for: key)
try await handleSync(key, data: securedData)
Logger.debug("<<< [STORAGE] SET SUCCESS: \(key.name)")
}
/// Retrieves a value for the given key.
/// - Parameter key: The storage key to retrieve.
/// - Returns: The stored value.
/// - Throws: `StorageError.notFound` if no value exists, plus domain-specific errors.
public func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value {
Logger.debug(">>> [STORAGE] GET: \(key.name)")
try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key)
// 1. Try primary location
if let securedData = try await retrieve(for: .from(key)) {
let data = try await applySecurity(securedData, for: .from(key), isEncrypt: false)
let result = try deserialize(data, with: key.serializer)
Logger.debug("<<< [STORAGE] GET SUCCESS: \(key.name)")
return result
}
// 2. Attempt migration (legacy or advanced)
if let migratedValue = try await attemptMigration(for: key) {
return migratedValue
}
Logger.debug("<<< [STORAGE] GET NOT FOUND: \(key.name)")
throw StorageError.notFound
}
/// Manually triggers migration for a specific key.
/// This is useful for "draining" old keys at app startup even if the destination already has data.
/// - Parameter key: The storage key to migrate.
/// - Returns: The migration result.
/// - Throws: Migration or storage errors.
public func forceMigration<Key: StorageKey>(for key: Key) async throws -> MigrationResult {
Logger.debug(">>> [STORAGE] MANUAL MIGRATION: \(key.name)")
try validateCatalogRegistration(for: key)
guard let migration = resolveMigration(for: key) else {
Logger.debug("<<< [STORAGE] MANUAL MIGRATION: No migration configured for \(key.name)")
return MigrationResult(success: true, migratedCount: 0)
}
let context = buildMigrationContext()
guard try await shouldAllowMigration(for: key, context: context) else {
Logger.debug("<<< [STORAGE] MANUAL MIGRATION: Skipped for \(key.name) on this platform")
return MigrationResult(success: true, migratedCount: 0)
}
let result = try await migration.migrate(using: self, context: context)
if result.success {
recordMigration(for: .from(key))
}
Logger.debug("<<< [STORAGE] MANUAL MIGRATION COMPLETE: \(key.name)")
return result
}
/// Removes the value for the given key.
/// - Parameter key: The storage key to remove.
/// - Throws: Domain-specific errors if removal fails.
public func remove<Key: StorageKey>(_ key: Key) async throws {
try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key)
try await delete(for: .from(key))
}
/// Checks if a value exists for the given key.
/// - Parameter key: The storage key to check.
/// - Returns: True if a value exists.
public func exists<Key: StorageKey>(_ key: Key) async throws -> Bool {
try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key)
switch key.domain {
case .userDefaults(let suite):
return try await defaults.exists(forKey: key.name, suite: suite)
case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier)
return try await defaults.exists(forKey: key.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
let resolvedService = try resolveService(service)
return try await keychain.exists(service: resolvedService, key: key.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
return await file.exists(in: directory, fileName: key.name)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
return await file.exists(
in: directory,
fileName: key.name,
appGroupIdentifier: resolvedId
)
}
}
internal func exists(descriptor: StorageKeyDescriptor) async throws -> Bool {
switch descriptor.domain {
case .userDefaults(let suite):
return try await defaults.exists(forKey: descriptor.name, suite: suite)
case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier)
return try await defaults.exists(forKey: descriptor.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
let resolvedService = try resolveService(service)
return try await keychain.exists(service: resolvedService, key: descriptor.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
return await file.exists(in: directory, fileName: descriptor.name)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
return await file.exists(
in: directory,
fileName: descriptor.name,
appGroupIdentifier: resolvedId
)
}
}
// MARK: - Platform Validation
nonisolated private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws {
#if os(watchOS)
if key.availability == .phoneOnly {
throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name)
}
#elseif os(iOS)
if key.availability == .watchOnly {
throw StorageError.watchOnlyKeyAccessedOnPhone(key.name)
}
#endif
}
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
guard !registeredKeys.isEmpty else { return }
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?"
Logger.error(errorMessage)
#if DEBUG
if !isRunningTests {
assertionFailure(errorMessage)
}
#endif
throw StorageError.unregisteredKey(key.name)
}
}
private var isRunningTests: Bool {
// Broad check for any test-related environment variables or classes
if ProcessInfo.processInfo.environment.keys.contains(where: {
$0.hasPrefix("XCTest") || $0.hasPrefix("SWIFT_TESTING") || $0.hasPrefix("SWIFT_DETERMINISTIC")
}) {
return true
}
return NSClassFromString("XCTestCase") != nil || NSClassFromString("Testing.Test") != nil
}
private func validateUniqueKeys(_ entries: [AnyStorageKey]) throws {
var exactNames: [String: Int] = [:]
var duplicates: [String] = []
for entry in entries {
exactNames[entry.descriptor.name, default: 0] += 1
}
for (name, count) in exactNames where count > 1 {
duplicates.append(name)
}
guard duplicates.isEmpty else {
throw StorageError.duplicateRegisteredKeys(duplicates.sorted())
}
}
private func validateDescription(_ entries: [AnyStorageKey]) throws {
let missing = entries
.map(\.descriptor)
.filter { $0.description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.map(\.name)
guard missing.isEmpty else {
throw StorageError.missingDescription(missing.sorted().joined(separator: ", "))
}
}
// MARK: - Migration
private func attemptMigration<Key: StorageKey>(for key: Key) async throws -> Key.Value? {
guard let migration = resolveMigration(for: key) else { return nil }
let context = buildMigrationContext()
let shouldMigrate = try await migration.shouldMigrate(using: self, context: context)
guard shouldMigrate else { return nil }
let result = try await migration.migrate(using: self, context: context)
if result.success {
recordMigration(for: .from(key))
if let securedData = try await retrieve(for: .from(key)) {
let data = try await applySecurity(securedData, for: .from(key), isEncrypt: false)
return try deserialize(data, with: key.serializer)
}
return nil
}
if let error = result.errors.first {
throw error
}
return nil
}
private func resolveMigration<Key: StorageKey>(for key: Key) -> AnyStorageMigration? {
key.migration
}
internal func buildMigrationContext() -> MigrationContext {
MigrationContext(migrationHistory: migrationHistory)
}
internal func recordMigration(for descriptor: StorageKeyDescriptor) {
migrationHistory[descriptor.name] = Date()
}
internal func shouldAllowMigration<Key: StorageKey>(
for key: Key,
context: MigrationContext
) async throws -> Bool {
guard key.availability.isAvailable(on: context.deviceInfo.platform) else {
return false
}
if key.syncPolicy != .never {
let isSyncAvailable = await sync.isSyncAvailable()
guard isSyncAvailable else { return false }
}
return true
}
// MARK: - Serialization
private func serialize<Value: Codable & Sendable>(
_ value: Value,
with serializer: Serializer<Value>
) throws -> Data {
do {
return try serializer.encode(value)
} catch {
throw StorageError.serializationFailed
}
}
nonisolated internal func deserialize<Value: Codable & Sendable>(
_ data: Data,
with serializer: Serializer<Value>
) throws -> Value {
do {
return try serializer.decode(data)
} catch {
throw StorageError.deserializationFailed
}
}
// MARK: - Security
internal func applySecurity(
_ data: Data,
for descriptor: StorageKeyDescriptor,
isEncrypt: Bool
) async throws -> Data {
switch descriptor.security {
case .none:
return data
case .encrypted(let encryptionPolicy):
await encryption.updateKeychainHelper(keychain)
if isEncrypt {
return try await encryption.encrypt(
data,
keyName: descriptor.name,
policy: encryptionPolicy
)
} else {
return try await encryption.decrypt(
data,
keyName: descriptor.name,
policy: encryptionPolicy
)
}
case .keychain:
// Keychain security is handled in store/retrieve
return data
}
}
// MARK: - Storage Operations
private func store(_ data: Data, for key: any StorageKey) async throws {
try await store(data, for: .from(key))
}
private func store(_ data: Data, for descriptor: StorageKeyDescriptor) async throws {
switch descriptor.domain {
case .userDefaults(let suite):
try await defaults.set(data, forKey: descriptor.name, suite: suite)
case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier)
try await defaults.set(data, forKey: descriptor.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
guard case let .keychain(accessibility, accessControl) = descriptor.security else {
throw StorageError.securityApplicationFailed
}
let resolvedService = try resolveService(service)
try await keychain.set(
data,
service: resolvedService,
key: descriptor.name,
accessibility: accessibility,
accessControl: accessControl
)
case .fileSystem(let directory):
try await file.write(
data,
to: directory,
fileName: descriptor.name,
useCompleteFileProtection: false
)
case .encryptedFileSystem(let directory):
try await file.write(
data,
to: directory,
fileName: descriptor.name,
useCompleteFileProtection: true
)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
try await file.write(
data,
to: directory,
fileName: descriptor.name,
appGroupIdentifier: resolvedId,
useCompleteFileProtection: false
)
}
}
internal func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
switch descriptor.domain {
case .userDefaults(let suite):
return try await defaults.get(forKey: descriptor.name, suite: suite)
case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier)
return try await defaults.get(forKey: descriptor.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
let resolvedService = try resolveService(service)
return try await keychain.get(service: resolvedService, key: descriptor.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
return try await file.read(from: directory, fileName: descriptor.name)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
return try await file.read(
from: directory,
fileName: descriptor.name,
appGroupIdentifier: resolvedId
)
}
}
internal func delete(for descriptor: StorageKeyDescriptor) async throws {
switch descriptor.domain {
case .userDefaults(let suite):
try await defaults.remove(forKey: descriptor.name, suite: suite)
case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier)
try await defaults.remove(forKey: descriptor.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
let resolvedService = try resolveService(service)
try await keychain.delete(service: resolvedService, key: descriptor.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
try await file.delete(from: directory, fileName: descriptor.name)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
try await file.delete(
from: directory,
fileName: descriptor.name,
appGroupIdentifier: resolvedId
)
}
}
// MARK: - Sync
private func handleSync(_ key: any StorageKey, data: Data) async throws {
try await sync.syncIfNeeded(
data: data,
keyName: key.name,
availability: key.availability,
syncPolicy: key.syncPolicy
)
}
/// Attempts to sync any registered keys that already have stored values.
/// This is useful for bootstrapping watch data after app launch or reconnection.
public func syncRegisteredKeysIfNeeded() async {
let isAvailable = await sync.isSyncAvailable()
guard isAvailable else {
Logger.debug("<<< [SYNC] Skipping bootstrap sync: WatchConnectivity unavailable")
return
}
Logger.debug(">>> [SYNC] Starting bootstrap sync for registered keys")
for entry in registeredKeys.values {
let descriptor = entry.descriptor
guard descriptor.availability == .all || descriptor.availability == .phoneWithWatchSync else {
Logger.debug("<<< [SYNC] Skipping key \(descriptor.name): availability=\(descriptor.availability)")
continue
}
guard descriptor.syncPolicy != .never else {
Logger.debug("<<< [SYNC] Skipping key \(descriptor.name): syncPolicy=\(descriptor.syncPolicy)")
continue
}
do {
guard let storedData = try await retrieve(for: descriptor) else {
Logger.debug("<<< [SYNC] No stored data for key \(descriptor.name)")
continue
}
try await sync.syncIfNeeded(
data: storedData,
keyName: descriptor.name,
availability: descriptor.availability,
syncPolicy: descriptor.syncPolicy
)
Logger.debug("<<< [SYNC] Bootstrapped context for key: \(descriptor.name) (\(storedData.count) bytes)")
} catch {
Logger.error("Failed to bootstrap sync for key: \(descriptor.name)", error: error)
}
}
}
/// Builds a snapshot of syncable key data for immediate watch requests.
public func syncSnapshot() async -> [String: Data] {
let isAvailable = await sync.isSyncAvailable()
guard isAvailable else {
Logger.debug("<<< [SYNC] Skipping snapshot: WatchConnectivity unavailable")
return [:]
}
let maxAutoSyncSize = await sync.maxAutoSyncSize()
var payload: [String: Data] = [:]
for entry in registeredKeys.values {
let descriptor = entry.descriptor
guard descriptor.availability == .all || descriptor.availability == .phoneWithWatchSync else {
continue
}
guard descriptor.syncPolicy != .never else { continue }
do {
guard let storedData = try await retrieve(for: descriptor) else { continue }
if descriptor.syncPolicy == .automaticSmall, storedData.count > maxAutoSyncSize {
Logger.debug("<<< [SYNC] Snapshot skip \(descriptor.name): size exceeds maxAutoSyncSize")
continue
}
payload[descriptor.name] = storedData
} catch {
Logger.error("Failed to build sync snapshot for key: \(descriptor.name)", error: error)
}
}
Logger.debug("<<< [SYNC] Snapshot built with \(payload.count) keys")
return payload
}
// MARK: - Internal Sync Handling
/// Internal method to update storage from received sync data.
/// This is called by SyncHelper when the paired device sends new context.
func updateFromSync(keyName: String, data: Data) async throws {
// Find the registered entry for this key
guard let entry = registeredKeys[keyName] else {
Logger.debug("Received sync data for unregistered or uncatalogued key: \(keyName)")
return
}
// The data received is already 'secured' (encrypted if necessary) by the sender.
// We can store it directly in our local domain.
try await store(data, for: entry.descriptor)
Logger.info("Successfully updated local storage from sync for key: \(keyName)")
}
// MARK: - Resolution Helpers
private func resolveService(_ service: String?) throws -> String {
guard let resolved = service ?? storageConfiguration.defaultKeychainService else {
Logger.error("No keychain service provided and no default configured")
throw StorageError.keychainError(errSecBadReq) // Or a more specific error
}
Logger.debug("Resolved Keychain Service: \(resolved)")
return resolved
}
private func resolveIdentifier(_ identifier: String?) throws -> String {
guard let resolved = identifier ?? storageConfiguration.defaultAppGroupIdentifier else {
Logger.error("No App Group identifier provided and no default configured")
throw StorageError.invalidAppGroupIdentifier("none")
}
Logger.debug("Resolved App Group ID: \(resolved)")
return resolved
}
}