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(-)
This commit is contained in:
Matt Bruce 2026-01-14 11:20:45 -06:00
parent 59e2be1306
commit 3f6856f124
11 changed files with 85 additions and 27 deletions

14
FutureEnhancements.md Normal file
View File

@ -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.

View File

@ -159,10 +159,10 @@ LocalData can generate a catalog of all configured storage keys, even if no data
```swift ```swift
struct AppStorageCatalog: StorageKeyCatalog { struct AppStorageCatalog: StorageKeyCatalog {
static var allKeys: [StorageKeyEntry] { static var allKeys: [AnyStorageKey] {
[ [
StorageKeyEntry(StorageKeys.AppVersionKey()), .key(StorageKeys.AppVersionKey()),
StorageKeyEntry(StorageKeys.UserPreferencesKey()) .key(StorageKeys.UserPreferencesKey())
] ]
} }
} }
@ -175,13 +175,18 @@ let report = StorageAuditReport.renderText(for: AppStorageCatalog.self)
print(report) print(report)
``` ```
3) Register the catalog to enforce usage: 3) Register the catalog to enforce usage and catch duplicates:
```swift ```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 ## Sample App
See `SecureStorgageSample` for working examples of all storage domains and security options. See `SecureStorgageSample` for working examples of all storage domains and security options.

View File

@ -0,0 +1,11 @@
public struct AnyStorageKey: Sendable {
public let descriptor: StorageKeyDescriptor
public init<Key: StorageKey>(_ key: Key) {
self.descriptor = .from(key)
}
public static func key<Key: StorageKey>(_ key: Key) -> AnyStorageKey {
AnyStorageKey(key)
}
}

View File

@ -11,6 +11,8 @@ public enum StorageError: Error {
case dataTooLargeForSync case dataTooLargeForSync
case notFound case notFound
case unregisteredKey(String) case unregisteredKey(String)
case duplicateRegisteredKeys([String])
case missingDescription(String)
// ... // ...
} }

View File

@ -1,6 +1,6 @@
import Foundation import Foundation
public struct StorageKeyDescriptor: Sendable { public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
public let name: String public let name: String
public let domain: StorageDomain public let domain: StorageDomain
public let security: SecurityPolicy public let security: SecurityPolicy
@ -9,7 +9,7 @@ public struct StorageKeyDescriptor: Sendable {
public let owner: String public let owner: String
public let availability: PlatformAvailability public let availability: PlatformAvailability
public let syncPolicy: SyncPolicy public let syncPolicy: SyncPolicy
public let notes: String? public let description: String
init( init(
name: String, name: String,
@ -20,7 +20,7 @@ public struct StorageKeyDescriptor: Sendable {
owner: String, owner: String,
availability: PlatformAvailability, availability: PlatformAvailability,
syncPolicy: SyncPolicy, syncPolicy: SyncPolicy,
notes: String? = nil description: String
) { ) {
self.name = name self.name = name
self.domain = domain self.domain = domain
@ -30,12 +30,11 @@ public struct StorageKeyDescriptor: Sendable {
self.owner = owner self.owner = owner
self.availability = availability self.availability = availability
self.syncPolicy = syncPolicy self.syncPolicy = syncPolicy
self.notes = notes self.description = description
} }
public static func from<Key: StorageKey>( public static func from<Key: StorageKey>(
_ key: Key, _ key: Key
notes: String? = nil
) -> StorageKeyDescriptor { ) -> StorageKeyDescriptor {
StorageKeyDescriptor( StorageKeyDescriptor(
name: key.name, name: key.name,
@ -46,7 +45,7 @@ public struct StorageKeyDescriptor: Sendable {
owner: key.owner, owner: key.owner,
availability: key.availability, availability: key.availability,
syncPolicy: key.syncPolicy, syncPolicy: key.syncPolicy,
notes: notes description: key.description
) )
} }
} }

View File

@ -1,7 +0,0 @@
public struct StorageKeyEntry: Sendable {
public let descriptor: StorageKeyDescriptor
public init<Key: StorageKey>(_ key: Key, notes: String? = nil) {
self.descriptor = .from(key, notes: notes)
}
}

View File

@ -1,6 +1,6 @@
import Foundation import Foundation
public protocol StorageKey: Sendable { public protocol StorageKey: Sendable, CustomStringConvertible {
associatedtype Value: Codable & Sendable associatedtype Value: Codable & Sendable
var name: String { get } var name: String { get }

View File

@ -1,3 +1,3 @@
public protocol StorageKeyCatalog { public protocol StorageKeyCatalog {
static var allKeys: [StorageKeyEntry] { get } static var allKeys: [AnyStorageKey] { get }
} }

View File

@ -20,9 +20,7 @@ public struct StorageAuditReport: Sendable {
parts.append("owner=\(item.owner)") parts.append("owner=\(item.owner)")
parts.append("availability=\(string(for: item.availability))") parts.append("availability=\(string(for: item.availability))")
parts.append("sync=\(string(for: item.syncPolicy))") parts.append("sync=\(string(for: item.syncPolicy))")
if let notes = item.notes, !notes.isEmpty { parts.append("description=\(item.description)")
parts.append("notes=\(notes)")
}
return parts.joined(separator: " | ") return parts.joined(separator: " | ")
} }
return lines.joined(separator: "\n") return lines.joined(separator: "\n")

View File

@ -25,8 +25,11 @@ public actor StorageRouter: StorageProviding {
/// Registers a catalog of known storage keys for audit and validation. /// Registers a catalog of known storage keys for audit and validation.
/// When registered, all storage operations will verify keys are listed. /// When registered, all storage operations will verify keys are listed.
public func registerCatalog<C: StorageKeyCatalog>(_ catalog: C.Type) { public func registerCatalog<C: StorageKeyCatalog>(_ catalog: C.Type) throws {
registeredKeyNames = Set(catalog.allKeys.map { $0.descriptor.name }) let entries = catalog.allKeys
try validateDescription(entries)
try validateUniqueKeys(entries)
registeredKeyNames = Set(entries.map { $0.descriptor.name })
} }
// MARK: - StorageProviding Implementation // MARK: - StorageProviding Implementation
@ -106,10 +109,41 @@ public actor StorageRouter: StorageProviding {
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws { private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
guard !registeredKeyNames.isEmpty else { return } guard !registeredKeyNames.isEmpty else { return }
guard registeredKeyNames.contains(key.name) else { guard registeredKeyNames.contains(key.name) else {
#if DEBUG
assertionFailure("StorageKey not registered in catalog: \(key.name)")
#endif
throw StorageError.unregisteredKey(key.name) 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 // MARK: - Serialization
private func serialize<Value: Codable & Sendable>( private func serialize<Value: Codable & Sendable>(

View File

@ -10,6 +10,7 @@ private struct TestUserDefaultsKey: StorageKey {
let security: SecurityPolicy = .none let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json let serializer: Serializer<String> = .json
let owner: String = "LocalDataTests" let owner: String = "LocalDataTests"
let description: String = "Test-only key for user defaults round-trip."
let availability: PlatformAvailability = .all let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never let syncPolicy: SyncPolicy = .never
@ -27,6 +28,7 @@ private struct TestFileKey: StorageKey {
let security: SecurityPolicy = .none let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json let serializer: Serializer<String> = .json
let owner: String = "LocalDataTests" let owner: String = "LocalDataTests"
let description: String = "Test-only key for file system round-trip."
let availability: PlatformAvailability = .all let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never let syncPolicy: SyncPolicy = .never