From 3f6856f12493172996f9a5563359ce1b59a04d5f Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 14 Jan 2026 11:20:45 -0600 Subject: [PATCH] Update Models, Protocols, Services + tests + docs Summary: - Sources: Models, Protocols, Services - Tests: LocalDataTests.swift - Docs: FutureEnhancements, README - Added symbols: struct AnyStorageKey, struct StorageKeyDescriptor, protocol StorageKey, func registerCatalog, func validateUniqueKeys, func validateDescription - Removed symbols: struct StorageKeyDescriptor, struct StorageKeyEntry, protocol StorageKey, func registerCatalog Stats: - 11 files changed, 85 insertions(+), 27 deletions(-) --- FutureEnhancements.md | 14 +++++++ README.md | 17 ++++++--- Sources/LocalData/Models/AnyStorageKey.swift | 11 ++++++ Sources/LocalData/Models/StorageError.swift | 2 + .../Models/StorageKeyDescriptor.swift | 13 +++---- .../LocalData/Models/StorageKeyEntry.swift | 7 ---- Sources/LocalData/Protocols/StorageKey.swift | 2 +- .../Protocols/StorageKeyCatalog.swift | 2 +- .../Services/StorageAuditReport.swift | 4 +- .../LocalData/Services/StorageRouter.swift | 38 ++++++++++++++++++- Tests/LocalDataTests/LocalDataTests.swift | 2 + 11 files changed, 85 insertions(+), 27 deletions(-) create mode 100644 FutureEnhancements.md create mode 100644 Sources/LocalData/Models/AnyStorageKey.swift delete mode 100644 Sources/LocalData/Models/StorageKeyEntry.swift diff --git a/FutureEnhancements.md b/FutureEnhancements.md new file mode 100644 index 0000000..b8fbbff --- /dev/null +++ b/FutureEnhancements.md @@ -0,0 +1,14 @@ +# Future Enhancements + +## Dynamic Key Names + +LocalData currently enforces exact, statically-registered key names for auditability. +If we decide to allow dynamic names later (e.g., per-account keys), the suggested design is: + +- Add a name rule type (e.g., `StorageKeyNameRule`) that supports `exact` and `prefix` matching. +- Extend `StorageKeyEntry` to carry a name rule. +- Update catalog registration to validate overlapping rules and duplicates. +- Update `StorageRouter` validation to accept keys that match the registered rules. +- Require a catalog entry that documents the dynamic name pattern and rationale. + +This keeps audit behavior explicit while allowing a controlled, documented escape hatch. diff --git a/README.md b/README.md index 3ecd082..7492933 100644 --- a/README.md +++ b/README.md @@ -159,10 +159,10 @@ LocalData can generate a catalog of all configured storage keys, even if no data ```swift struct AppStorageCatalog: StorageKeyCatalog { - static var allKeys: [StorageKeyEntry] { + static var allKeys: [AnyStorageKey] { [ - StorageKeyEntry(StorageKeys.AppVersionKey()), - StorageKeyEntry(StorageKeys.UserPreferencesKey()) + .key(StorageKeys.AppVersionKey()), + .key(StorageKeys.UserPreferencesKey()) ] } } @@ -175,13 +175,18 @@ let report = StorageAuditReport.renderText(for: AppStorageCatalog.self) print(report) ``` -3) Register the catalog to enforce usage: +3) Register the catalog to enforce usage and catch duplicates: ```swift -StorageRouter.shared.registerCatalog(AppStorageCatalog.self) +do { + try StorageRouter.shared.registerCatalog(AppStorageCatalog.self) +} catch { + assertionFailure("Storage catalog registration failed: \(error)") +} ``` -For dynamic key names, use a placeholder name and a note to describe how it is generated. +Dynamic key names are intentionally not supported in the core API to keep storage auditing strict and predictable. +If you need this later, see `FutureEnhancements.md` for a proposed design. ## Sample App See `SecureStorgageSample` for working examples of all storage domains and security options. diff --git a/Sources/LocalData/Models/AnyStorageKey.swift b/Sources/LocalData/Models/AnyStorageKey.swift new file mode 100644 index 0000000..d31bbfd --- /dev/null +++ b/Sources/LocalData/Models/AnyStorageKey.swift @@ -0,0 +1,11 @@ +public struct AnyStorageKey: Sendable { + public let descriptor: StorageKeyDescriptor + + public init(_ key: Key) { + self.descriptor = .from(key) + } + + public static func key(_ key: Key) -> AnyStorageKey { + AnyStorageKey(key) + } +} diff --git a/Sources/LocalData/Models/StorageError.swift b/Sources/LocalData/Models/StorageError.swift index a138375..192b6a5 100644 --- a/Sources/LocalData/Models/StorageError.swift +++ b/Sources/LocalData/Models/StorageError.swift @@ -11,6 +11,8 @@ public enum StorageError: Error { case dataTooLargeForSync case notFound case unregisteredKey(String) + case duplicateRegisteredKeys([String]) + case missingDescription(String) // ... } diff --git a/Sources/LocalData/Models/StorageKeyDescriptor.swift b/Sources/LocalData/Models/StorageKeyDescriptor.swift index cb4d9c5..44e015b 100644 --- a/Sources/LocalData/Models/StorageKeyDescriptor.swift +++ b/Sources/LocalData/Models/StorageKeyDescriptor.swift @@ -1,6 +1,6 @@ import Foundation -public struct StorageKeyDescriptor: Sendable { +public struct StorageKeyDescriptor: Sendable, CustomStringConvertible { public let name: String public let domain: StorageDomain public let security: SecurityPolicy @@ -9,7 +9,7 @@ public struct StorageKeyDescriptor: Sendable { public let owner: String public let availability: PlatformAvailability public let syncPolicy: SyncPolicy - public let notes: String? + public let description: String init( name: String, @@ -20,7 +20,7 @@ public struct StorageKeyDescriptor: Sendable { owner: String, availability: PlatformAvailability, syncPolicy: SyncPolicy, - notes: String? = nil + description: String ) { self.name = name self.domain = domain @@ -30,12 +30,11 @@ public struct StorageKeyDescriptor: Sendable { self.owner = owner self.availability = availability self.syncPolicy = syncPolicy - self.notes = notes + self.description = description } public static func from( - _ key: Key, - notes: String? = nil + _ key: Key ) -> StorageKeyDescriptor { StorageKeyDescriptor( name: key.name, @@ -46,7 +45,7 @@ public struct StorageKeyDescriptor: Sendable { owner: key.owner, availability: key.availability, syncPolicy: key.syncPolicy, - notes: notes + description: key.description ) } } diff --git a/Sources/LocalData/Models/StorageKeyEntry.swift b/Sources/LocalData/Models/StorageKeyEntry.swift deleted file mode 100644 index b3f1ac9..0000000 --- a/Sources/LocalData/Models/StorageKeyEntry.swift +++ /dev/null @@ -1,7 +0,0 @@ -public struct StorageKeyEntry: Sendable { - public let descriptor: StorageKeyDescriptor - - public init(_ key: Key, notes: String? = nil) { - self.descriptor = .from(key, notes: notes) - } -} diff --git a/Sources/LocalData/Protocols/StorageKey.swift b/Sources/LocalData/Protocols/StorageKey.swift index a13f305..506f16c 100644 --- a/Sources/LocalData/Protocols/StorageKey.swift +++ b/Sources/LocalData/Protocols/StorageKey.swift @@ -1,6 +1,6 @@ import Foundation -public protocol StorageKey: Sendable { +public protocol StorageKey: Sendable, CustomStringConvertible { associatedtype Value: Codable & Sendable var name: String { get } diff --git a/Sources/LocalData/Protocols/StorageKeyCatalog.swift b/Sources/LocalData/Protocols/StorageKeyCatalog.swift index 9c2786f..1c0cfa0 100644 --- a/Sources/LocalData/Protocols/StorageKeyCatalog.swift +++ b/Sources/LocalData/Protocols/StorageKeyCatalog.swift @@ -1,3 +1,3 @@ public protocol StorageKeyCatalog { - static var allKeys: [StorageKeyEntry] { get } + static var allKeys: [AnyStorageKey] { get } } diff --git a/Sources/LocalData/Services/StorageAuditReport.swift b/Sources/LocalData/Services/StorageAuditReport.swift index b446ba3..bb303dc 100644 --- a/Sources/LocalData/Services/StorageAuditReport.swift +++ b/Sources/LocalData/Services/StorageAuditReport.swift @@ -20,9 +20,7 @@ public struct StorageAuditReport: Sendable { parts.append("owner=\(item.owner)") parts.append("availability=\(string(for: item.availability))") parts.append("sync=\(string(for: item.syncPolicy))") - if let notes = item.notes, !notes.isEmpty { - parts.append("notes=\(notes)") - } + parts.append("description=\(item.description)") return parts.joined(separator: " | ") } return lines.joined(separator: "\n") diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift index 617b372..67b8697 100644 --- a/Sources/LocalData/Services/StorageRouter.swift +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -25,8 +25,11 @@ public actor StorageRouter: StorageProviding { /// Registers a catalog of known storage keys for audit and validation. /// When registered, all storage operations will verify keys are listed. - public func registerCatalog(_ catalog: C.Type) { - registeredKeyNames = Set(catalog.allKeys.map { $0.descriptor.name }) + public func registerCatalog(_ catalog: C.Type) throws { + let entries = catalog.allKeys + try validateDescription(entries) + try validateUniqueKeys(entries) + registeredKeyNames = Set(entries.map { $0.descriptor.name }) } // MARK: - StorageProviding Implementation @@ -106,9 +109,40 @@ public actor StorageRouter: StorageProviding { private func validateCatalogRegistration(for key: Key) throws { guard !registeredKeyNames.isEmpty else { return } guard registeredKeyNames.contains(key.name) else { +#if DEBUG + assertionFailure("StorageKey not registered in catalog: \(key.name)") +#endif throw StorageError.unregisteredKey(key.name) } } + + 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: - Serialization diff --git a/Tests/LocalDataTests/LocalDataTests.swift b/Tests/LocalDataTests/LocalDataTests.swift index 3ae429b..548b923 100644 --- a/Tests/LocalDataTests/LocalDataTests.swift +++ b/Tests/LocalDataTests/LocalDataTests.swift @@ -10,6 +10,7 @@ private struct TestUserDefaultsKey: StorageKey { let security: SecurityPolicy = .none let serializer: Serializer = .json let owner: String = "LocalDataTests" + let description: String = "Test-only key for user defaults round-trip." let availability: PlatformAvailability = .all let syncPolicy: SyncPolicy = .never @@ -27,6 +28,7 @@ private struct TestFileKey: StorageKey { let security: SecurityPolicy = .none let serializer: Serializer = .json let owner: String = "LocalDataTests" + let description: String = "Test-only key for file system round-trip." let availability: PlatformAvailability = .all let syncPolicy: SyncPolicy = .never