From c234214c90cbff28db01e616d96515bee65c0693 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 16 Jan 2026 10:15:11 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- README.md | 21 +++++++++--------- .../LocalData/Audit/StorageAuditReport.swift | 6 ++--- .../Protocols/StorageKeyCatalog.swift | 14 ++++++++++-- .../LocalData/Services/StorageRouter.swift | 4 ++-- Tests/LocalDataTests/AuditTests.swift | 4 ++-- .../LocalDataTests/ModularRegistryTests.swift | 22 +++++++++---------- Tests/LocalDataTests/RouterErrorTests.swift | 4 ++-- .../LocalDataTests/StorageCatalogTests.swift | 22 +++++++++---------- .../LocalDataTests/SyncIntegrationTests.swift | 4 ++-- 9 files changed, 55 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 9d11ee0..520d292 100644 --- a/README.md +++ b/README.md @@ -201,10 +201,10 @@ When registering a catalog, you can enable `migrateImmediately` to perform a glo ```swift // Module A -try await StorageRouter.shared.registerCatalog(AuthCatalog.self) +try await StorageRouter.shared.registerCatalog(AuthCatalog()) // Module B -try await StorageRouter.shared.registerCatalog(ProfileCatalog.self) +try await StorageRouter.shared.registerCatalog(ProfileCatalog()) ``` ## Storage Design Philosophy @@ -378,27 +378,26 @@ LocalData can generate a catalog of all configured storage keys, even if no data ```swift struct AppStorageCatalog: StorageKeyCatalog { - static var allKeys: [AnyStorageKey] { - [ - .key(StorageKeys.AppVersionKey()), - .key(StorageKeys.UserPreferencesKey()) - ] - } + let allKeys: [AnyStorageKey] = [ + .key(StorageKeys.AppVersionKey()), + .key(StorageKeys.UserPreferencesKey()) + ] } ``` 2) Generate a report: ```swift -let report = StorageAuditReport.renderText(for: AppStorageCatalog.self) +let report = StorageAuditReport.renderText(AppStorageCatalog()) print(report) ``` -3) Register the catalog to enforce usage and catch duplicates: +3) Create and register the catalog to enforce usage and catch duplicates: ```swift +let appCatalog = AppStorageCatalog() do { - try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self) + try await StorageRouter.shared.registerCatalog(appCatalog) } catch { assertionFailure("Storage catalog registration failed: \(error)") } diff --git a/Sources/LocalData/Audit/StorageAuditReport.swift b/Sources/LocalData/Audit/StorageAuditReport.swift index 59f15ef..0326f58 100644 --- a/Sources/LocalData/Audit/StorageAuditReport.swift +++ b/Sources/LocalData/Audit/StorageAuditReport.swift @@ -1,11 +1,11 @@ import Foundation public struct StorageAuditReport: Sendable { - public static func items(for catalog: C.Type) -> [StorageKeyDescriptor] { - catalog.allKeys.map(\.descriptor) + public static func items(for catalog: some StorageKeyCatalog) -> [StorageKeyDescriptor] { + catalog.allKeys.map { $0.descriptor.withCatalog(catalog.name) } } - public static func renderText(for catalog: C.Type) -> String { + public static func renderText(_ catalog: some StorageKeyCatalog) -> String { renderText(items(for: catalog)) } diff --git a/Sources/LocalData/Protocols/StorageKeyCatalog.swift b/Sources/LocalData/Protocols/StorageKeyCatalog.swift index 1c0cfa0..38e25a5 100644 --- a/Sources/LocalData/Protocols/StorageKeyCatalog.swift +++ b/Sources/LocalData/Protocols/StorageKeyCatalog.swift @@ -1,3 +1,13 @@ -public protocol StorageKeyCatalog { - static var allKeys: [AnyStorageKey] { get } +public protocol StorageKeyCatalog: Sendable { + var name: String { get } + var allKeys: [AnyStorageKey] { get } +} + +extension StorageKeyCatalog { + public var name: String { + let fullName = String(describing: type(of: self)) + // Simple cleanup for generic or nested names if needed, + // but describes the type clearly enough for audit. + return fullName + } } diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift index 9a26232..6fc198f 100644 --- a/Sources/LocalData/Services/StorageRouter.swift +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -76,13 +76,13 @@ public actor StorageRouter: StorageProviding { /// - Parameters: /// - catalog: The catalog type to register. /// - migrateImmediately: If true, triggers a proactive migration (sweep) for all keys in the catalog. - public func registerCatalog(_ catalog: C.Type, migrateImmediately: Bool = false) async throws { + public func registerCatalog(_ catalog: some StorageKeyCatalog, migrateImmediately: Bool = false) async throws { let entries = catalog.allKeys try validateDescription(entries) try validateUniqueKeys(entries) // Validate against existing registrations to prevent collisions across catalogs - let catalogName = String(describing: catalog) + let catalogName = catalog.name Logger.info(">>> [STORAGE] Registering Catalog: \(catalogName) (\(entries.count) keys)") for entry in entries { diff --git a/Tests/LocalDataTests/AuditTests.swift b/Tests/LocalDataTests/AuditTests.swift index d744408..ce503f3 100644 --- a/Tests/LocalDataTests/AuditTests.swift +++ b/Tests/LocalDataTests/AuditTests.swift @@ -5,7 +5,7 @@ import Testing @Suite struct AuditTests { private struct AuditCatalog: StorageKeyCatalog { - static var allKeys: [AnyStorageKey] { + var allKeys: [AnyStorageKey] { [ .key(TestKey(name: "k1", domain: .userDefaults(suite: nil))), .key(TestKey(name: "k2", domain: .keychain(service: "s"), security: .keychain(accessibility: .afterFirstUnlock, accessControl: .userPresence))), @@ -35,7 +35,7 @@ import Testing } @Test func renderCatalogText() { - let text = StorageAuditReport.renderText(for: AuditCatalog.self) + let text = StorageAuditReport.renderText(AuditCatalog()) #expect(text.contains("name=k1")) #expect(text.contains("domain=userDefaults(standard)")) diff --git a/Tests/LocalDataTests/ModularRegistryTests.swift b/Tests/LocalDataTests/ModularRegistryTests.swift index 227091e..581940f 100644 --- a/Tests/LocalDataTests/ModularRegistryTests.swift +++ b/Tests/LocalDataTests/ModularRegistryTests.swift @@ -21,19 +21,19 @@ private struct TestRegistryKey: StorageKey { } private struct CatalogA: StorageKeyCatalog { - static var allKeys: [AnyStorageKey] { + var allKeys: [AnyStorageKey] { [.key(TestRegistryKey(name: "key.a", owner: "ModuleA"))] } } private struct CatalogB: StorageKeyCatalog { - static var allKeys: [AnyStorageKey] { + var allKeys: [AnyStorageKey] { [.key(TestRegistryKey(name: "key.b", owner: "ModuleB"))] } } private struct CatalogCollision: StorageKeyCatalog { - static var allKeys: [AnyStorageKey] { + var allKeys: [AnyStorageKey] { [.key(TestRegistryKey(name: "key.a", owner: "ModuleCollision"))] } } @@ -44,11 +44,11 @@ struct ModularRegistryTests { @Test func testAdditiveRegistration() async throws { let router = StorageRouter(keychain: MockKeychainHelper()) - try await router.registerCatalog(CatalogA.self) + try await router.registerCatalog(CatalogA()) #expect(await router.allRegisteredEntries().count == 1) #expect(await router.allRegisteredEntries().contains { $0.descriptor.name == "key.a" }) - try await router.registerCatalog(CatalogB.self) + try await router.registerCatalog(CatalogB()) #expect(await router.allRegisteredEntries().count == 2) #expect(await router.allRegisteredEntries().contains { $0.descriptor.name == "key.b" }) } @@ -56,18 +56,18 @@ struct ModularRegistryTests { @Test func testCollisionDetectionAcrossCatalogs() async throws { let router = StorageRouter(keychain: MockKeychainHelper()) - try await router.registerCatalog(CatalogA.self) + try await router.registerCatalog(CatalogA()) await #expect(throws: StorageError.self) { - try await router.registerCatalog(CatalogCollision.self) + try await router.registerCatalog(CatalogCollision()) } } @Test func testGlobalAuditReport() async throws { let router = StorageRouter(keychain: MockKeychainHelper()) - try await router.registerCatalog(CatalogA.self) - try await router.registerCatalog(CatalogB.self) + try await router.registerCatalog(CatalogA()) + try await router.registerCatalog(CatalogB()) // Note: StorageAuditReport.renderGlobalRegistry() uses the shared router. // For testing isolation, we should probably add a parameter to it or @@ -88,8 +88,8 @@ struct ModularRegistryTests { @Test func testGlobalAuditReportGrouped() async throws { let router = StorageRouter(keychain: MockKeychainHelper()) - try await router.registerCatalog(CatalogA.self) - try await router.registerCatalog(CatalogB.self) + try await router.registerCatalog(CatalogA()) + try await router.registerCatalog(CatalogB()) let catalogs = await router.allRegisteredCatalogs() diff --git a/Tests/LocalDataTests/RouterErrorTests.swift b/Tests/LocalDataTests/RouterErrorTests.swift index 71df28f..b784674 100644 --- a/Tests/LocalDataTests/RouterErrorTests.swift +++ b/Tests/LocalDataTests/RouterErrorTests.swift @@ -15,7 +15,7 @@ private struct MockKey: StorageKey { } private struct PartialCatalog: StorageKeyCatalog { - static var allKeys: [AnyStorageKey] { + var allKeys: [AnyStorageKey] { [.key(MockKey(name: "registered.key", domain: .userDefaults(suite: nil)))] } } @@ -35,7 +35,7 @@ struct RouterErrorTests { } @Test func unregisteredKeyThrows() async throws { - try await router.registerCatalog(PartialCatalog.self) + try await router.registerCatalog(PartialCatalog()) let badKey = MockKey(name: "unregistered.key", domain: .userDefaults(suite: nil)) diff --git a/Tests/LocalDataTests/StorageCatalogTests.swift b/Tests/LocalDataTests/StorageCatalogTests.swift index 0e01b4f..c3141b7 100644 --- a/Tests/LocalDataTests/StorageCatalogTests.swift +++ b/Tests/LocalDataTests/StorageCatalogTests.swift @@ -25,7 +25,7 @@ private struct TestCatalogKey: StorageKey { // MARK: - Test Catalogs private struct ValidCatalog: StorageKeyCatalog { - static var allKeys: [AnyStorageKey] { + var allKeys: [AnyStorageKey] { [ .key(TestCatalogKey(name: "valid.key1", description: "First test key")), .key(TestCatalogKey(name: "valid.key2", description: "Second test key")) @@ -34,7 +34,7 @@ private struct ValidCatalog: StorageKeyCatalog { } private struct DuplicateNameCatalog: StorageKeyCatalog { - static var allKeys: [AnyStorageKey] { + var allKeys: [AnyStorageKey] { [ .key(TestCatalogKey(name: "duplicate.name", description: "First instance")), .key(TestCatalogKey(name: "duplicate.name", description: "Second instance")) @@ -43,11 +43,11 @@ private struct DuplicateNameCatalog: StorageKeyCatalog { } private struct EmptyCatalog: StorageKeyCatalog { - static var allKeys: [AnyStorageKey] { [] } + var allKeys: [AnyStorageKey] { [] } } private struct MissingDescriptionCatalog: StorageKeyCatalog { - static var allKeys: [AnyStorageKey] { + var allKeys: [AnyStorageKey] { [ .key(TestCatalogKey(name: "missing.desc", description: " ")) ] @@ -61,7 +61,7 @@ struct StorageCatalogTests { private let router = StorageRouter(keychain: MockKeychainHelper()) @Test func auditReportContainsAllKeys() { - let items = StorageAuditReport.items(for: ValidCatalog.self) + let items = StorageAuditReport.items(for: ValidCatalog()) #expect(items.count == 2) #expect(items[0].name == "valid.key1") @@ -69,7 +69,7 @@ struct StorageCatalogTests { } @Test func auditReportRendersText() { - let report = StorageAuditReport.renderText(for: ValidCatalog.self) + let report = StorageAuditReport.renderText(ValidCatalog()) #expect(report.contains("valid.key1")) #expect(report.contains("valid.key2")) @@ -89,30 +89,30 @@ struct StorageCatalogTests { } @Test func emptyReportForEmptyCatalog() { - let items = StorageAuditReport.items(for: EmptyCatalog.self) + let items = StorageAuditReport.items(for: EmptyCatalog()) #expect(items.isEmpty) - let report = StorageAuditReport.renderText(for: EmptyCatalog.self) + let report = StorageAuditReport.renderText(EmptyCatalog()) #expect(report.isEmpty) } @Test func catalogRegistrationDetectsDuplicates() async { // Attempting to register a catalog with duplicate key names should throw await #expect(throws: StorageError.self) { - try await router.registerCatalog(DuplicateNameCatalog.self) + try await router.registerCatalog(DuplicateNameCatalog()) } } @Test func catalogRegistrationDetectsMissingDescriptions() async { // Attempting to register a catalog with missing descriptions should throw await #expect(throws: StorageError.self) { - try await router.registerCatalog(MissingDescriptionCatalog.self) + try await router.registerCatalog(MissingDescriptionCatalog()) } } @Test func migrateAllRegisteredKeysInvokesMigrationOnKeys() async throws { // This test verifies that migrateAllRegisteredKeys calling logic works. - try await router.registerCatalog(ValidCatalog.self) + try await router.registerCatalog(ValidCatalog()) // No error should occur try await router.migrateAllRegisteredKeys() diff --git a/Tests/LocalDataTests/SyncIntegrationTests.swift b/Tests/LocalDataTests/SyncIntegrationTests.swift index e55845e..818a0c2 100644 --- a/Tests/LocalDataTests/SyncIntegrationTests.swift +++ b/Tests/LocalDataTests/SyncIntegrationTests.swift @@ -29,14 +29,14 @@ struct SyncIntegrationTests { } private struct SyncCatalog: StorageKeyCatalog { - static var allKeys: [AnyStorageKey] { + var allKeys: [AnyStorageKey] { [.key(SyncKey(name: "sync.test.key"))] } } @Test func updateFromSyncStoresDataLocally() async throws { // 1. Register catalog so router knows about the key - try await router.registerCatalog(SyncCatalog.self) + try await router.registerCatalog(SyncCatalog()) let keyName = "sync.test.key" let expectedValue = "Updated from Watch"