Compare commits

..

No commits in common. "b83b89b9ac359a74c9d4590dd9d6822acf3a8d3e" and "e7561f5c108d8122e0fea1c39172168cdbf17c32" have entirely different histories.

13 changed files with 49 additions and 280 deletions

View File

@ -32,17 +32,6 @@ Run all tests from the package root:
swift test
```
### Xcode Command Line
For packages that use platform-specific features (like `WatchConnectivity`), use `xcodebuild`:
```bash
# Run on an available simulator (e.g., iPhone 16)
xcodebuild test -scheme LocalData -destination 'platform=iOS Simulator,name=iPhone 16'
# Or using the specific device ID
xcodebuild test -scheme LocalData -destination 'id=4BF0FAE0-8FC1-4E19-89F4-2EDF12A28847'
```
### Xcode Test Plans
For granular control (enabling/disabling specific tests or trying different configurations), use **Xcode Test Plans**:

View File

@ -50,13 +50,11 @@ 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 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)
Apps can register a `StorageKeyCatalog` to generate audit reports and enforce key registration. Registration validates:
- Duplicate key names
- 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,17 +194,10 @@ 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.
> [!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.
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.
```swift
// Module A
try await StorageRouter.shared.registerCatalog(AuthCatalog())
// Module B
try await StorageRouter.shared.registerCatalog(ProfileCatalog())
try await StorageRouter.shared.registerCatalog(AppStorageCatalog.self, migrateImmediately: true)
```
## Storage Design Philosophy
@ -378,43 +371,32 @@ LocalData can generate a catalog of all configured storage keys, even if no data
```swift
struct AppStorageCatalog: StorageKeyCatalog {
let allKeys: [AnyStorageKey] = [
.key(StorageKeys.AppVersionKey()),
.key(StorageKeys.UserPreferencesKey())
]
static var allKeys: [AnyStorageKey] {
[
.key(StorageKeys.AppVersionKey()),
.key(StorageKeys.UserPreferencesKey())
]
}
}
```
2) Generate a report:
```swift
let report = StorageAuditReport.renderText(AppStorageCatalog())
let report = StorageAuditReport.renderText(for: AppStorageCatalog.self)
print(report)
```
3) Create and register the catalog to enforce usage and catch duplicates:
3) Register the catalog to enforce usage and catch duplicates:
```swift
let appCatalog = AppStorageCatalog()
do {
try await StorageRouter.shared.registerCatalog(appCatalog)
try 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

@ -1,46 +1,18 @@
import Foundation
public struct StorageAuditReport: Sendable {
public static func items(for catalog: some StorageKeyCatalog) -> [StorageKeyDescriptor] {
catalog.allKeys.map { $0.descriptor.withCatalog(catalog.name) }
public static func items<C: StorageKeyCatalog>(for catalog: C.Type) -> [StorageKeyDescriptor] {
catalog.allKeys.map(\.descriptor)
}
public static func renderText(_ catalog: some StorageKeyCatalog) -> String {
public static func renderText<C: StorageKeyCatalog>(for catalog: C.Type) -> String {
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 internal(set) var descriptor: StorageKeyDescriptor
public let descriptor: StorageKeyDescriptor
private let migrateAction: @Sendable (StorageRouter) async throws -> Void
public init<Key: StorageKey>(_ key: Key) {
@ -9,20 +9,10 @@ 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,7 +10,6 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
public let availability: PlatformAvailability
public let syncPolicy: SyncPolicy
public let description: String
public let catalog: String?
init(
name: String,
@ -21,8 +20,7 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
owner: String,
availability: PlatformAvailability,
syncPolicy: SyncPolicy,
description: String,
catalog: String? = nil
description: String
) {
self.name = name
self.domain = domain
@ -33,12 +31,10 @@ 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,
catalog: String? = nil
_ key: Key
) -> StorageKeyDescriptor {
StorageKeyDescriptor(
name: key.name,
@ -49,24 +45,7 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
owner: key.owner,
availability: key.availability,
syncPolicy: key.syncPolicy,
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
description: key.description
)
}
}

View File

@ -1,13 +1,3 @@
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
}
public protocol StorageKeyCatalog {
static var allKeys: [AnyStorageKey] { get }
}

View File

@ -8,8 +8,8 @@ public actor StorageRouter: StorageProviding {
public static let shared = StorageRouter()
private var catalogRegistries: [String: [AnyStorageKey]] = [:]
private var registeredKeys: [String: AnyStorageKey] = [:]
private var registeredKeyNames: Set<String> = []
private var registeredEntries: [AnyStorageKey] = []
private var storageConfiguration: StorageConfiguration = .default
private let keychain: KeychainStoring
private let encryption: EncryptionHelper
@ -76,52 +76,23 @@ 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: some StorageKeyCatalog, migrateImmediately: Bool = false) async throws {
public func registerCatalog<C: StorageKeyCatalog>(_ catalog: C.Type, 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 = catalog.name
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)")
registeredKeyNames = Set(entries.map { $0.descriptor.name })
registeredEntries = entries
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 registeredKeys.values {
for entry in registeredEntries {
try await entry.migrate(on: self)
}
Logger.debug("<<< [STORAGE] GLOBAL MIGRATION SWEEP COMPLETE")
@ -271,13 +242,11 @@ public actor StorageRouter: StorageProviding {
}
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
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)
guard !registeredKeyNames.isEmpty else { return }
guard registeredKeyNames.contains(key.name) else {
#if DEBUG
if !isRunningTests {
assertionFailure(errorMessage)
assertionFailure("StorageKey not registered in catalog: \(key.name)")
}
#endif
throw StorageError.unregisteredKey(key.name)
@ -500,7 +469,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 = registeredKeys[keyName] else {
guard let entry = registeredEntries.first(where: { $0.descriptor.name == keyName }) else {
Logger.debug("Received sync data for unregistered or uncatalogued key: \(keyName)")
return
}

View File

@ -5,7 +5,7 @@ import Testing
@Suite struct AuditTests {
private struct AuditCatalog: StorageKeyCatalog {
var allKeys: [AnyStorageKey] {
static 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(AuditCatalog())
let text = StorageAuditReport.renderText(for: AuditCatalog.self)
#expect(text.contains("name=k1"))
#expect(text.contains("domain=userDefaults(standard)"))

View File

@ -1,100 +0,0 @@
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 {
var allKeys: [AnyStorageKey] {
[.key(TestRegistryKey(name: "key.a", owner: "ModuleA"))]
}
}
private struct CatalogB: StorageKeyCatalog {
var allKeys: [AnyStorageKey] {
[.key(TestRegistryKey(name: "key.b", owner: "ModuleB"))]
}
}
private struct CatalogCollision: StorageKeyCatalog {
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())
#expect(await router.allRegisteredEntries().count == 1)
#expect(await router.allRegisteredEntries().contains { $0.descriptor.name == "key.a" })
try await router.registerCatalog(CatalogB())
#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())
await #expect(throws: StorageError.self) {
try await router.registerCatalog(CatalogCollision())
}
}
@Test func testGlobalAuditReport() async throws {
let router = StorageRouter(keychain: MockKeychainHelper())
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
// 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())
try await router.registerCatalog(CatalogB())
let catalogs = await router.allRegisteredCatalogs()
#expect(catalogs.count == 2)
#expect(catalogs["CatalogA"] != nil)
#expect(catalogs["CatalogB"] != nil)
}
}

View File

@ -15,7 +15,7 @@ private struct MockKey: StorageKey {
}
private struct PartialCatalog: StorageKeyCatalog {
var allKeys: [AnyStorageKey] {
static 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())
try await router.registerCatalog(PartialCatalog.self)
let badKey = MockKey(name: "unregistered.key", domain: .userDefaults(suite: nil))

View File

@ -25,7 +25,7 @@ private struct TestCatalogKey: StorageKey {
// MARK: - Test Catalogs
private struct ValidCatalog: StorageKeyCatalog {
var allKeys: [AnyStorageKey] {
static 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 {
var allKeys: [AnyStorageKey] {
static 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 {
var allKeys: [AnyStorageKey] { [] }
static var allKeys: [AnyStorageKey] { [] }
}
private struct MissingDescriptionCatalog: StorageKeyCatalog {
var allKeys: [AnyStorageKey] {
static 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())
let items = StorageAuditReport.items(for: ValidCatalog.self)
#expect(items.count == 2)
#expect(items[0].name == "valid.key1")
@ -69,7 +69,7 @@ struct StorageCatalogTests {
}
@Test func auditReportRendersText() {
let report = StorageAuditReport.renderText(ValidCatalog())
let report = StorageAuditReport.renderText(for: ValidCatalog.self)
#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())
let items = StorageAuditReport.items(for: EmptyCatalog.self)
#expect(items.isEmpty)
let report = StorageAuditReport.renderText(EmptyCatalog())
let report = StorageAuditReport.renderText(for: EmptyCatalog.self)
#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())
try await router.registerCatalog(DuplicateNameCatalog.self)
}
}
@Test func catalogRegistrationDetectsMissingDescriptions() async {
// Attempting to register a catalog with missing descriptions should throw
await #expect(throws: StorageError.self) {
try await router.registerCatalog(MissingDescriptionCatalog())
try await router.registerCatalog(MissingDescriptionCatalog.self)
}
}
@Test func migrateAllRegisteredKeysInvokesMigrationOnKeys() async throws {
// This test verifies that migrateAllRegisteredKeys calling logic works.
try await router.registerCatalog(ValidCatalog())
try await router.registerCatalog(ValidCatalog.self)
// No error should occur
try await router.migrateAllRegisteredKeys()

View File

@ -29,14 +29,14 @@ struct SyncIntegrationTests {
}
private struct SyncCatalog: StorageKeyCatalog {
var allKeys: [AnyStorageKey] {
static 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())
try await router.registerCatalog(SyncCatalog.self)
let keyName = "sync.test.key"
let expectedValue = "Updated from Watch"