Update Audit, Models, Services + tests + docs

Summary:
- Sources: Audit, Models, Services
- Tests: ModularRegistryTests.swift
- Docs: Proposal, README
- Added symbols: func withCatalog, func allRegisteredCatalogs, func allRegisteredEntries, struct TestRegistryKey, typealias Value, struct CatalogA (+3 more)

Stats:
- 7 files changed, 229 insertions(+), 18 deletions(-)
This commit is contained in:
Matt Bruce 2026-01-16 10:05:27 -06:00
parent 16475c69c7
commit 8c8d4a0db8
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. Apps extend StorageKeys with their own key types and use StorageRouter.shared. This follows the Notification.Name pattern for discoverable keys.
## Audit & Validation ## Audit & Validation
Apps can register a `StorageKeyCatalog` to generate audit reports and enforce key registration. Registration validates: 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 - Duplicate key names (across all registered catalogs)
- Missing descriptions - Missing descriptions
- Unregistered keys at runtime (debug assertions) - Unregistered keys at runtime (debug assertions)
The `StorageRouter` provides discovery APIs to retrieve all registered keys for global audit reporting.
## Sync Behavior ## 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. 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 #### 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 ```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 ## Storage Design Philosophy
@ -391,12 +398,24 @@ print(report)
```swift ```swift
do { do {
try StorageRouter.shared.registerCatalog(AppStorageCatalog.self) try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self)
} catch { } catch {
assertionFailure("Storage catalog registration failed: \(error)") 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. 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. 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. 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)) 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 { public static func renderText(_ items: [StorageKeyDescriptor]) -> String {
let lines = items.map { item in let lines = items.map { item in
var parts: [String] = [] var parts: [String] = []
parts.append("name=\(item.name)") parts.append("name=\(item.name)")
if let catalog = item.catalog {
parts.append("catalog=\(catalog)")
}
parts.append("domain=\(string(for: item.domain))") parts.append("domain=\(string(for: item.domain))")
parts.append("security=\(string(for: item.security))") parts.append("security=\(string(for: item.security))")
parts.append("serializer=\(item.serializer)") parts.append("serializer=\(item.serializer)")

View File

@ -1,5 +1,5 @@
public struct AnyStorageKey: Sendable { public struct AnyStorageKey: Sendable {
public let descriptor: StorageKeyDescriptor public internal(set) var descriptor: StorageKeyDescriptor
private let migrateAction: @Sendable (StorageRouter) async throws -> Void private let migrateAction: @Sendable (StorageRouter) async throws -> Void
public init<Key: StorageKey>(_ key: Key) { 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 { public static func key<Key: StorageKey>(_ key: Key) -> AnyStorageKey {
AnyStorageKey(key) 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 use: Triggers the migration logic for this key.
internal func migrate(on router: StorageRouter) async throws { internal func migrate(on router: StorageRouter) async throws {
try await migrateAction(router) try await migrateAction(router)

View File

@ -10,6 +10,7 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
public let availability: PlatformAvailability public let availability: PlatformAvailability
public let syncPolicy: SyncPolicy public let syncPolicy: SyncPolicy
public let description: String public let description: String
public let catalog: String?
init( init(
name: String, name: String,
@ -20,7 +21,8 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
owner: String, owner: String,
availability: PlatformAvailability, availability: PlatformAvailability,
syncPolicy: SyncPolicy, syncPolicy: SyncPolicy,
description: String description: String,
catalog: String? = nil
) { ) {
self.name = name self.name = name
self.domain = domain self.domain = domain
@ -31,10 +33,12 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
self.availability = availability self.availability = availability
self.syncPolicy = syncPolicy self.syncPolicy = syncPolicy
self.description = description self.description = description
self.catalog = catalog
} }
public static func from<Key: StorageKey>( public static func from<Key: StorageKey>(
_ key: Key _ key: Key,
catalog: String? = nil
) -> StorageKeyDescriptor { ) -> StorageKeyDescriptor {
StorageKeyDescriptor( StorageKeyDescriptor(
name: key.name, name: key.name,
@ -45,7 +49,24 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
owner: key.owner, owner: key.owner,
availability: key.availability, availability: key.availability,
syncPolicy: key.syncPolicy, 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() public static let shared = StorageRouter()
private var registeredKeyNames: Set<String> = [] private var catalogRegistries: [String: [AnyStorageKey]] = [:]
private var registeredEntries: [AnyStorageKey] = [] private var registeredKeys: [String: AnyStorageKey] = [:]
private var storageConfiguration: StorageConfiguration = .default private var storageConfiguration: StorageConfiguration = .default
private let keychain: KeychainStoring private let keychain: KeychainStoring
private let encryption: EncryptionHelper private let encryption: EncryptionHelper
@ -80,19 +80,48 @@ public actor StorageRouter: StorageProviding {
let entries = catalog.allKeys let entries = catalog.allKeys
try validateDescription(entries) try validateDescription(entries)
try validateUniqueKeys(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 { if migrateImmediately {
try await migrateAllRegisteredKeys() 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. /// Triggers a proactive migration (sweep) for all registered storage keys.
/// This "drains" any legacy data into the modern storage locations. /// This "drains" any legacy data into the modern storage locations.
public func migrateAllRegisteredKeys() async throws { public func migrateAllRegisteredKeys() async throws {
Logger.debug(">>> [STORAGE] STARTING GLOBAL MIGRATION SWEEP") Logger.debug(">>> [STORAGE] STARTING GLOBAL MIGRATION SWEEP")
for entry in registeredEntries { for entry in registeredKeys.values {
try await entry.migrate(on: self) try await entry.migrate(on: self)
} }
Logger.debug("<<< [STORAGE] GLOBAL MIGRATION SWEEP COMPLETE") Logger.debug("<<< [STORAGE] GLOBAL MIGRATION SWEEP COMPLETE")
@ -242,11 +271,13 @@ 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 !registeredKeys.isEmpty else { return }
guard registeredKeyNames.contains(key.name) else { 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 DEBUG
if !isRunningTests { if !isRunningTests {
assertionFailure("StorageKey not registered in catalog: \(key.name)") assertionFailure(errorMessage)
} }
#endif #endif
throw StorageError.unregisteredKey(key.name) 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. /// This is called by SyncHelper when the paired device sends new context.
func updateFromSync(keyName: String, data: Data) async throws { func updateFromSync(keyName: String, data: Data) async throws {
// Find the registered entry for this key // 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)") Logger.debug("Received sync data for unregistered or uncatalogued key: \(keyName)")
return 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)
}
}