diff --git a/Proposal.md b/Proposal.md index 2520422..1332c05 100644 --- a/Proposal.md +++ b/Proposal.md @@ -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. diff --git a/README.md b/README.md index c0ba1f6..9d11ee0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/LocalData/Audit/StorageAuditReport.swift b/Sources/LocalData/Audit/StorageAuditReport.swift index 5febcaf..59f15ef 100644 --- a/Sources/LocalData/Audit/StorageAuditReport.swift +++ b/Sources/LocalData/Audit/StorageAuditReport.swift @@ -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)") diff --git a/Sources/LocalData/Models/AnyStorageKey.swift b/Sources/LocalData/Models/AnyStorageKey.swift index aafbb9d..f3f2de6 100644 --- a/Sources/LocalData/Models/AnyStorageKey.swift +++ b/Sources/LocalData/Models/AnyStorageKey.swift @@ -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: 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: 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) diff --git a/Sources/LocalData/Models/StorageKeyDescriptor.swift b/Sources/LocalData/Models/StorageKeyDescriptor.swift index 44e015b..719899b 100644 --- a/Sources/LocalData/Models/StorageKeyDescriptor.swift +++ b/Sources/LocalData/Models/StorageKeyDescriptor.swift @@ -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: 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 ) } } diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift index 282d8d3..9a26232 100644 --- a/Sources/LocalData/Services/StorageRouter.swift +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -8,8 +8,8 @@ public actor StorageRouter: StorageProviding { public static let shared = StorageRouter() - private var registeredKeyNames: Set = [] - 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(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 } diff --git a/Tests/LocalDataTests/ModularRegistryTests.swift b/Tests/LocalDataTests/ModularRegistryTests.swift new file mode 100644 index 0000000..227091e --- /dev/null +++ b/Tests/LocalDataTests/ModularRegistryTests.swift @@ -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 = .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) + } +}