Update Audit, Models, Services and tests, docs

Summary:
- Sources: update Audit, Models, Services
- Tests: add tests for ModularRegistryTests.swift
- Docs: update docs for Proposal, README

Stats:
- 7 files changed, 229 insertions(+), 18 deletions(-)
This commit is contained in:
Matt Bruce 2026-01-16 10:05:27 -06:00
parent 50be09a906
commit 93c71b0bfe
7 changed files with 229 additions and 18 deletions

View File

@ -50,11 +50,13 @@ Each helper is a dedicated actor providing thread-safe access to a specific stor
Apps extend StorageKeys with their own key types and use StorageRouter.shared. This follows the Notification.Name pattern for discoverable keys.
## Audit & Validation
Apps can register a `StorageKeyCatalog` to generate audit reports and enforce key registration. Registration validates:
- Duplicate key names
Apps can register multiple `StorageKeyCatalog`s (e.g., one per module) to generate audit reports and enforce key registration. Registration is additive and validates:
- Duplicate key names (across all registered catalogs)
- Missing descriptions
- Unregistered keys at runtime (debug assertions)
The `StorageRouter` provides discovery APIs to retrieve all registered keys for global audit reporting.
## Sync Behavior
StorageRouter can call WCSession.updateApplicationContext for manual or automaticSmall sync policies when availability allows it. Session activation and receiving data are owned by the app.

View File

@ -194,10 +194,17 @@ try await StorageRouter.shared.migrate(for: StorageKeys.ModernKey())
```
#### Automated Startup Sweep
When registering a catalog, you can enable `migrateImmediately` to perform a global sweep of all legacy keys for every key in the catalog. This ensures your storage is clean at every app launch.
When registering a catalog, you can enable `migrateImmediately` to perform a global sweep of all legacy keys for every key in the catalog.
> [!NOTE]
> **Modular Registration**: `registerCatalog` is additive. You can call it multiple times from different modules to build an aggregate registry. The library will throw an error if multiple catalogs attempt to register the same key name.
```swift
try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self, migrateImmediately: true)
// Module A
try await StorageRouter.shared.registerCatalog(AuthCatalog.self)
// Module B
try await StorageRouter.shared.registerCatalog(ProfileCatalog.self)
```
## Storage Design Philosophy
@ -391,12 +398,24 @@ print(report)
```swift
do {
try StorageRouter.shared.registerCatalog(AppStorageCatalog.self)
try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self)
} catch {
assertionFailure("Storage catalog registration failed: \(error)")
}
```
4) Render a global report of all registered keys across all catalogs:
```swift
// Flat list
let globalReport = await StorageAuditReport.renderGlobalRegistry()
print(globalReport)
// Grouped by catalog module
let groupedReport = await StorageAuditReport.renderGlobalRegistryGrouped()
print(groupedReport)
```
Each `StorageKey` must provide a human-readable `description` used in audit reports.
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.

View File

@ -9,10 +9,38 @@ public struct StorageAuditReport: Sendable {
renderText(items(for: catalog))
}
public static func renderText(_ entries: [AnyStorageKey]) -> String {
renderText(entries.map(\.descriptor))
}
public static func renderGlobalRegistry() async -> String {
let entries = await StorageRouter.shared.allRegisteredEntries()
return renderText(entries)
}
public static func renderGlobalRegistryGrouped() async -> String {
let catalogs = await StorageRouter.shared.allRegisteredCatalogs()
var reportLines: [String] = []
for catalogName in catalogs.keys.sorted() {
reportLines.append("=== \(catalogName) ===")
if let entries = catalogs[catalogName] {
let keysReport = renderText(entries)
reportLines.append(keysReport)
}
reportLines.append("")
}
return reportLines.joined(separator: "\n")
}
public static func renderText(_ items: [StorageKeyDescriptor]) -> String {
let lines = items.map { item in
var parts: [String] = []
parts.append("name=\(item.name)")
if let catalog = item.catalog {
parts.append("catalog=\(catalog)")
}
parts.append("domain=\(string(for: item.domain))")
parts.append("security=\(string(for: item.security))")
parts.append("serializer=\(item.serializer)")

View File

@ -1,5 +1,5 @@
public struct AnyStorageKey: Sendable {
public let descriptor: StorageKeyDescriptor
public internal(set) var descriptor: StorageKeyDescriptor
private let migrateAction: @Sendable (StorageRouter) async throws -> Void
public init<Key: StorageKey>(_ key: Key) {
@ -9,10 +9,20 @@ public struct AnyStorageKey: Sendable {
}
}
private init(descriptor: StorageKeyDescriptor, migrateAction: @escaping @Sendable (StorageRouter) async throws -> Void) {
self.descriptor = descriptor
self.migrateAction = migrateAction
}
public static func key<Key: StorageKey>(_ key: Key) -> AnyStorageKey {
AnyStorageKey(key)
}
/// Internal use: Returns a copy of this key with the catalog name set.
internal func withCatalog(_ name: String) -> AnyStorageKey {
AnyStorageKey(descriptor: descriptor.withCatalog(name), migrateAction: migrateAction)
}
/// Internal use: Triggers the migration logic for this key.
internal func migrate(on router: StorageRouter) async throws {
try await migrateAction(router)

View File

@ -10,6 +10,7 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
public let availability: PlatformAvailability
public let syncPolicy: SyncPolicy
public let description: String
public let catalog: String?
init(
name: String,
@ -20,7 +21,8 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
owner: String,
availability: PlatformAvailability,
syncPolicy: SyncPolicy,
description: String
description: String,
catalog: String? = nil
) {
self.name = name
self.domain = domain
@ -31,10 +33,12 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
self.availability = availability
self.syncPolicy = syncPolicy
self.description = description
self.catalog = catalog
}
public static func from<Key: StorageKey>(
_ key: Key
_ key: Key,
catalog: String? = nil
) -> StorageKeyDescriptor {
StorageKeyDescriptor(
name: key.name,
@ -45,7 +49,24 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
owner: key.owner,
availability: key.availability,
syncPolicy: key.syncPolicy,
description: key.description
description: key.description,
catalog: catalog
)
}
/// Returns a new descriptor with the catalog name set.
public func withCatalog(_ catalogName: String) -> StorageKeyDescriptor {
StorageKeyDescriptor(
name: self.name,
domain: domain,
security: security,
serializer: serializer,
valueType: valueType,
owner: owner,
availability: availability,
syncPolicy: syncPolicy,
description: description,
catalog: catalogName
)
}
}

View File

@ -8,8 +8,8 @@ public actor StorageRouter: StorageProviding {
public static let shared = StorageRouter()
private var registeredKeyNames: Set<String> = []
private var registeredEntries: [AnyStorageKey] = []
private var catalogRegistries: [String: [AnyStorageKey]] = [:]
private var registeredKeys: [String: AnyStorageKey] = [:]
private var storageConfiguration: StorageConfiguration = .default
private let keychain: KeychainStoring
private let encryption: EncryptionHelper
@ -80,19 +80,48 @@ public actor StorageRouter: StorageProviding {
let entries = catalog.allKeys
try validateDescription(entries)
try validateUniqueKeys(entries)
registeredKeyNames = Set(entries.map { $0.descriptor.name })
registeredEntries = entries
// Validate against existing registrations to prevent collisions across catalogs
let catalogName = String(describing: catalog)
Logger.info(">>> [STORAGE] Registering Catalog: \(catalogName) (\(entries.count) keys)")
for entry in entries {
if let existing = registeredKeys[entry.descriptor.name] {
let existingCatalog = existing.descriptor.catalog ?? "Unknown"
let errorMessage = "STORAGE KEY COLLISION: Key name '\(entry.descriptor.name)' in \(catalogName) is already registered by \(existingCatalog)."
Logger.error(errorMessage)
throw StorageError.duplicateRegisteredKeys([entry.descriptor.name])
}
}
// Add to registry with catalog name context
catalogRegistries[catalogName] = entries
for entry in entries {
registeredKeys[entry.descriptor.name] = entry.withCatalog(catalogName)
}
Logger.info("<<< [STORAGE] Catalog Registered Successfully: \(catalogName)")
if migrateImmediately {
try await migrateAllRegisteredKeys()
}
}
/// Returns all currently registered storage keys, grouped by catalog name.
public func allRegisteredCatalogs() -> [String: [AnyStorageKey]] {
catalogRegistries
}
/// Returns all currently registered storage keys.
public func allRegisteredEntries() -> [AnyStorageKey] {
Array(registeredKeys.values)
}
/// Triggers a proactive migration (sweep) for all registered storage keys.
/// This "drains" any legacy data into the modern storage locations.
public func migrateAllRegisteredKeys() async throws {
Logger.debug(">>> [STORAGE] STARTING GLOBAL MIGRATION SWEEP")
for entry in registeredEntries {
for entry in registeredKeys.values {
try await entry.migrate(on: self)
}
Logger.debug("<<< [STORAGE] GLOBAL MIGRATION SWEEP COMPLETE")
@ -242,11 +271,13 @@ public actor StorageRouter: StorageProviding {
}
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
guard !registeredKeyNames.isEmpty else { return }
guard registeredKeyNames.contains(key.name) else {
guard !registeredKeys.isEmpty else { return }
guard registeredKeys[key.name] != nil else {
let errorMessage = "UNREGISTERED STORAGE KEY: '\(key.name)' (Owner: \(key.owner)) accessed but not found in any registered catalog. Did you forget to call registerCatalog?"
Logger.error(errorMessage)
#if DEBUG
if !isRunningTests {
assertionFailure("StorageKey not registered in catalog: \(key.name)")
assertionFailure(errorMessage)
}
#endif
throw StorageError.unregisteredKey(key.name)
@ -469,7 +500,7 @@ public actor StorageRouter: StorageProviding {
/// This is called by SyncHelper when the paired device sends new context.
func updateFromSync(keyName: String, data: Data) async throws {
// Find the registered entry for this key
guard let entry = registeredEntries.first(where: { $0.descriptor.name == keyName }) else {
guard let entry = registeredKeys[keyName] else {
Logger.debug("Received sync data for unregistered or uncatalogued key: \(keyName)")
return
}

View File

@ -0,0 +1,100 @@
import Foundation
import Testing
@testable import LocalData
private struct TestRegistryKey: StorageKey {
typealias Value = String
let name: String
let domain: StorageDomain = .userDefaults(suite: nil)
let security: SecurityPolicy = .none
let serializer: Serializer<String> = .json
let owner: String
let description: String
let availability: PlatformAvailability = .all
let syncPolicy: SyncPolicy = .never
init(name: String, owner: String = "Test", description: String = "Test") {
self.name = name
self.owner = owner
self.description = description
}
}
private struct CatalogA: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] {
[.key(TestRegistryKey(name: "key.a", owner: "ModuleA"))]
}
}
private struct CatalogB: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] {
[.key(TestRegistryKey(name: "key.b", owner: "ModuleB"))]
}
}
private struct CatalogCollision: StorageKeyCatalog {
static var allKeys: [AnyStorageKey] {
[.key(TestRegistryKey(name: "key.a", owner: "ModuleCollision"))]
}
}
@Suite(.serialized)
struct ModularRegistryTests {
@Test func testAdditiveRegistration() async throws {
let router = StorageRouter(keychain: MockKeychainHelper())
try await router.registerCatalog(CatalogA.self)
#expect(await router.allRegisteredEntries().count == 1)
#expect(await router.allRegisteredEntries().contains { $0.descriptor.name == "key.a" })
try await router.registerCatalog(CatalogB.self)
#expect(await router.allRegisteredEntries().count == 2)
#expect(await router.allRegisteredEntries().contains { $0.descriptor.name == "key.b" })
}
@Test func testCollisionDetectionAcrossCatalogs() async throws {
let router = StorageRouter(keychain: MockKeychainHelper())
try await router.registerCatalog(CatalogA.self)
await #expect(throws: StorageError.self) {
try await router.registerCatalog(CatalogCollision.self)
}
}
@Test func testGlobalAuditReport() async throws {
let router = StorageRouter(keychain: MockKeychainHelper())
try await router.registerCatalog(CatalogA.self)
try await router.registerCatalog(CatalogB.self)
// Note: StorageAuditReport.renderGlobalRegistry() uses the shared router.
// For testing isolation, we should probably add a parameter to it or
// rely on the shared instance if we can reset it.
// Since StorageRouter is a singleton-heavy actor, we use renderText directly on the router's entries.
let entries = await router.allRegisteredEntries()
let report = StorageAuditReport.renderText(entries)
#expect(report.contains("key.a"))
#expect(report.contains("key.b"))
#expect(report.contains("catalog=CatalogA"))
#expect(report.contains("catalog=CatalogB"))
#expect(report.contains("ModuleA"))
#expect(report.contains("ModuleB"))
}
@Test func testGlobalAuditReportGrouped() async throws {
let router = StorageRouter(keychain: MockKeychainHelper())
try await router.registerCatalog(CatalogA.self)
try await router.registerCatalog(CatalogB.self)
let catalogs = await router.allRegisteredCatalogs()
#expect(catalogs.count == 2)
#expect(catalogs["CatalogA"] != nil)
#expect(catalogs["CatalogB"] != nil)
}
}