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:
parent
59e2be1306
commit
3f6856f124
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
|
```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.
|
||||||
|
|||||||
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 dataTooLargeForSync
|
||||||
case notFound
|
case notFound
|
||||||
case unregisteredKey(String)
|
case unregisteredKey(String)
|
||||||
|
case duplicateRegisteredKeys([String])
|
||||||
|
case missingDescription(String)
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
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 }
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
public protocol StorageKeyCatalog {
|
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("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")
|
||||||
|
|||||||
@ -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>(
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user