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

This commit is contained in:
Matt Bruce 2026-01-14 11:20:45 -06:00
parent b8ab728c37
commit ffca729794
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
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.

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 notFound
case unregisteredKey(String)
case duplicateRegisteredKeys([String])
case missingDescription(String)
// ...
}

View File

@ -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: StorageKey>(
_ 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
)
}
}

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
public protocol StorageKey: Sendable {
public protocol StorageKey: Sendable, CustomStringConvertible {
associatedtype Value: Codable & Sendable
var name: String { get }

View File

@ -1,3 +1,3 @@
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("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")

View File

@ -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<C: StorageKeyCatalog>(_ catalog: C.Type) {
registeredKeyNames = Set(catalog.allKeys.map { $0.descriptor.name })
public func registerCatalog<C: StorageKeyCatalog>(_ 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,10 +109,41 @@ public actor StorageRouter: StorageProviding {
private func validateCatalogRegistration<Key: StorageKey>(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
private func serialize<Value: Codable & Sendable>(

View File

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