diff --git a/Sources/LocalData/Documentation.docc/GettingStarted.md b/Sources/LocalData/Documentation.docc/GettingStarted.md new file mode 100644 index 0000000..fae9d78 --- /dev/null +++ b/Sources/LocalData/Documentation.docc/GettingStarted.md @@ -0,0 +1,58 @@ +# Getting Started + +Follow this guide to define keys, configure LocalData, and register catalogs for auditing. + +## 1) Define keys + +Keys are static, typed constants. Do not use dynamic key names. + +```swift +import LocalData + +extension StorageKey where Value == String { + static let userToken = StorageKey( + name: "user_token", + domain: .keychain(service: "com.myapp"), + owner: "Auth", + description: "Current user auth token." + ) +} +``` + +## 2) Configure the router (optional) + +Set defaults once at app launch to avoid repeating service identifiers. + +```swift +let storageConfig = StorageConfiguration( + defaultKeychainService: "com.myapp.keychain", + defaultAppGroupIdentifier: "group.com.myapp" +) +await StorageRouter.shared.updateStorageConfiguration(storageConfig) +``` + +## 3) Register catalogs + +Catalogs are mandatory if you want auditing and duplicate detection. + +```swift +struct AuthCatalog: StorageKeyCatalog { + let allKeys: [AnyStorageKey] = [ + .key(StorageKey.userToken) + ] +} + +try await StorageRouter.shared.registerCatalog(AuthCatalog()) +``` + +## 4) Store and read values + +```swift +try await StorageRouter.shared.set("token", for: .userToken) +let token = try await StorageRouter.shared.get(.userToken) +``` + +## Next steps + +- Read to avoid audit gaps. +- Add migrations in . diff --git a/Sources/LocalData/Documentation.docc/KeyAndCatalogDiscipline.md b/Sources/LocalData/Documentation.docc/KeyAndCatalogDiscipline.md new file mode 100644 index 0000000..f0e0c59 --- /dev/null +++ b/Sources/LocalData/Documentation.docc/KeyAndCatalogDiscipline.md @@ -0,0 +1,39 @@ +# Key and Catalog Discipline + +This package is designed for static, discoverable keys. Treat your key catalog as your storage manifest. + +## Static keys only + +Do not generate dynamic key names at runtime. If a value can exist, it should have a corresponding +static ``StorageKey`` definition. This keeps audits accurate and prevents invisible storage usage. + +If you must represent multiple instances (for example, per-account settings), create a static key +for each supported slot or store a dictionary keyed by account ID inside a single ``StorageKey``. + +## Avoid audit gaps + +Catalog registration enforces correctness: + +- Missing descriptions are rejected. +- Duplicate key names across catalogs are rejected. +- Unregistered keys throw ``StorageError/unregisteredKey(_:)`` at runtime. + +If you skip catalog registration, LocalData cannot guarantee that all writes are auditable. + +## Modular catalogs + +Split catalogs by feature or module and register them incrementally: + +```swift +try await StorageRouter.shared.registerCatalog(AuthCatalog()) +try await StorageRouter.shared.registerCatalog(ProfileCatalog()) +``` + +Registration is additive. A collision in key names across catalogs throws an error. + +## Recommended workflow + +1. Define keys in `StorageKeys/` per feature. +2. Create a catalog for each feature. +3. Register all catalogs on app launch. +4. Run ``StorageAuditReport`` to verify output. diff --git a/Sources/LocalData/Documentation.docc/LocalData.md b/Sources/LocalData/Documentation.docc/LocalData.md new file mode 100644 index 0000000..9fca678 --- /dev/null +++ b/Sources/LocalData/Documentation.docc/LocalData.md @@ -0,0 +1,43 @@ +# LocalData + +LocalData is a typed, auditable storage layer that unifies UserDefaults, Keychain, and file storage under a single API. It favors static, discoverable keys and explicit catalogs so teams can reason about what is stored, where it lives, and how it is secured. + +## Overview + +LocalData revolves around three concepts: + +- ``StorageKey`` defines storage metadata for a single value. +- ``StorageRouter`` coordinates serialization, security, and routing. +- ``StorageKeyCatalog`` registers keys for auditing and migration. + +## Topics + +### Getting Started + +- +- + +### Migrations + +- + +### Sync + +- + +### Security + +- + +### Testing + +- + +### Reference + +- ``StorageRouter`` +- ``StorageKey`` +- ``StorageKeyCatalog`` +- ``StorageDomain`` +- ``SecurityPolicy`` +- ``StorageError`` diff --git a/Sources/LocalData/Documentation.docc/Migrations.md b/Sources/LocalData/Documentation.docc/Migrations.md new file mode 100644 index 0000000..03bbd37 --- /dev/null +++ b/Sources/LocalData/Documentation.docc/Migrations.md @@ -0,0 +1,72 @@ +# Migrations + +LocalData supports lazy and proactive migrations to move data from legacy keys to modern keys. + +## Lazy migrations (on read) + +Attach a migration to a key. If the destination is missing, `StorageRouter.get(_:)` will run it. + +```swift +extension StorageKey where Value == String { + static let legacyToken = StorageKey( + name: "legacy_token", + domain: .userDefaults(suite: nil), + security: .none, + owner: "Auth", + description: "Legacy token." + ) + + static let userToken = StorageKey( + name: "user_token", + domain: .keychain(service: "com.myapp"), + owner: "Auth", + description: "Modern token.", + migration: { destination in + AnyStorageMigration( + SimpleLegacyMigration( + destinationKey: destination, + sourceKey: .key(StorageKey.legacyToken) + ) + ) + } + ) +} +``` + +## Proactive sweeps + +Run migrations at startup to drain legacy values: + +```swift +try await StorageRouter.shared.registerCatalog(AuthCatalog(), migrateImmediately: true) +``` + +## Transforming migrations + +Use `DefaultTransformingMigration` when types change. + +```swift +let migration = DefaultTransformingMigration( + destinationKey: StorageKey.userAge, + sourceKey: StorageKey.legacyAgeString +) { value in + guard let intValue = Int(value) else { + throw MigrationError.transformationFailed("Invalid age") + } + return intValue +} +``` + +## Aggregating migrations + +Combine multiple legacy values into one destination: + +```swift +let migration = DefaultAggregatingMigration( + destinationKey: StorageKey.profileSummary, + sourceKeys: [.key(StorageKey.firstName), .key(StorageKey.lastName)] +) { sources in + let parts = sources.compactMap { $0.value as? String } + return parts.joined(separator: " ") +} +``` diff --git a/Sources/LocalData/Documentation.docc/Security.md b/Sources/LocalData/Documentation.docc/Security.md new file mode 100644 index 0000000..395793a --- /dev/null +++ b/Sources/LocalData/Documentation.docc/Security.md @@ -0,0 +1,61 @@ +# Security + +Security is declared per key using ``SecurityPolicy``. + +## Recommended default + +Use the default security policy unless you have a specific reason not to: + +```swift +let key = StorageKey( + name: "secure_value", + domain: .fileSystem(directory: .documents), + owner: "Security", + description: "Sensitive value stored with recommended policy." +) +``` + +## Keychain security + +Store data directly in Keychain with accessibility and access control: + +```swift +let key = StorageKey( + name: "token", + domain: .keychain(service: "com.myapp"), + security: .keychain( + accessibility: .afterFirstUnlock, + accessControl: .biometryAny + ), + owner: "Auth", + description: "Auth token." +) +``` + +## Encrypted file storage + +Use encryption for file-based storage: + +```swift +let key = StorageKey( + name: "secret_file", + domain: .encryptedFileSystem(directory: .documents), + owner: "Vault", + description: "Encrypted file data." +) +``` + +## External key material + +Register a provider and reference it in the policy: + +```swift +struct RemoteKeyProvider: KeyMaterialProviding { + func keyMaterial(for keyName: String) async throws -> Data { + Data(repeating: 1, count: 32) + } +} + +let source = KeyMaterialSource(id: "remote.key") +await StorageRouter.shared.registerKeyMaterialProvider(RemoteKeyProvider(), for: source) +``` diff --git a/Sources/LocalData/Documentation.docc/Testing.md b/Sources/LocalData/Documentation.docc/Testing.md new file mode 100644 index 0000000..7854d0e --- /dev/null +++ b/Sources/LocalData/Documentation.docc/Testing.md @@ -0,0 +1,32 @@ +# Testing + +Use isolated helpers and custom configurations to avoid polluting real storage. + +## Use temporary file locations + +```swift +let testURL = FileManager.default.temporaryDirectory + .appending(path: "LocalDataTests-\(UUID().uuidString)") + +let router = StorageRouter( + keychain: MockKeychainHelper(), + encryption: EncryptionHelper(keychain: MockKeychainHelper()), + file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testURL)), + defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "LocalDataTests")!) +) +``` + +## App Group identifiers + +Use a mock `StorageConfiguration` to avoid real entitlements: + +```swift +await router.updateStorageConfiguration( + StorageConfiguration(defaultAppGroupIdentifier: "group.test") +) +``` + +## Keychain in simulator + +Keychain tests in the simulator often require a host app with entitlements. +If you see `-34018`, ensure the test target has a host app and keychain sharing. diff --git a/Sources/LocalData/Documentation.docc/WatchSync.md b/Sources/LocalData/Documentation.docc/WatchSync.md new file mode 100644 index 0000000..3b6c471 --- /dev/null +++ b/Sources/LocalData/Documentation.docc/WatchSync.md @@ -0,0 +1,34 @@ +# Watch Sync + +LocalData supports WatchConnectivity application context sync for eligible keys. + +## Eligibility rules + +A key syncs only when: + +- `availability` is `.all` or `.phoneWithWatchSync` +- `syncPolicy` is `.manual` or `.automaticSmall` + +For `.automaticSmall`, the payload must be below `SyncConfiguration.maxAutoSyncSize`. + +## Bootstrapping at launch + +Call this on iOS after session activation or reachability changes: + +```swift +await StorageRouter.shared.syncRegisteredKeysIfNeeded() +``` + +## Responding to watch requests + +Build a snapshot and return it via `WCSession`: + +```swift +let snapshot = await StorageRouter.shared.syncSnapshot() +``` + +## Best practices + +- Activate `WCSession` in the app, not in LocalData. +- Use a launch-order-safe handshake (watch first or phone first). +- Avoid large payloads; prefer smaller derived values for watch display.