Update Models, Protocols, Services and tests, docs
Summary: - Sources: update Models, Protocols, Services - Tests: update tests for LocalDataTests.swift - Docs: update docs for FutureEnhancements, README Stats: - 11 files changed, 85 insertions(+), 27 deletions(-)
This commit is contained in:
parent
7357c7161a
commit
68e0bccb27
14
FutureEnhancements.md
Normal file
14
FutureEnhancements.md
Normal 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.
|
||||
17
README.md
17
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.
|
||||
|
||||
11
Sources/LocalData/Models/AnyStorageKey.swift
Normal file
11
Sources/LocalData/Models/AnyStorageKey.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,8 @@ public enum StorageError: Error {
|
||||
case dataTooLargeForSync
|
||||
case notFound
|
||||
case unregisteredKey(String)
|
||||
case duplicateRegisteredKeys([String])
|
||||
case missingDescription(String)
|
||||
// ...
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public protocol StorageKey: Sendable {
|
||||
public protocol StorageKey: Sendable, CustomStringConvertible {
|
||||
associatedtype Value: Codable & Sendable
|
||||
|
||||
var name: String { get }
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
public protocol StorageKeyCatalog {
|
||||
static var allKeys: [StorageKeyEntry] { get }
|
||||
static var allKeys: [AnyStorageKey] { get }
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,9 +109,40 @@ 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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user