diff --git a/apps/render-ios-playground/render-ios-playground/Domain/Store/README.md b/apps/render-ios-playground/render-ios-playground/Domain/Store/README.md new file mode 100644 index 0000000..32fba30 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Domain/Store/README.md @@ -0,0 +1,174 @@ +# Store API Implementation + +This directory contains the complete implementation of the Store API for the iOS Render SDK as specified in `/docs/specs/store.spec.md`. + +## Architecture + +The Store API is organized into the following layers: + +``` +Domain/Store/ +├── StoreValue.swift # Core data model for all storable values +├── StorePatch.swift # Represents changes to store values +├── StoreEnums.swift # Scope and Storage enums +├── StoreValidation.swift # Validation system +├── StoreProtocol.swift # Store and StoreFactory protocols +└── StoreError.swift # Error handling + +Infrastructure/Store/ +├── BaseStore.swift # Base implementation of Store protocol +├── StoreStorageBackend.swift # Storage backend protocol and utilities +├── StoreFactory.swift # Default StoreFactory implementation +├── ScenarioStoreFactory.swift # Scenario-specific factory +├── MemoryStorageBackend.swift # In-memory storage +├── UserDefaultsStorageBackend.swift # UserDefaults storage +├── FileStorageBackend.swift # File-based storage +└── ScenarioSessionStorageBackend.swift # Scenario session storage + +SDK/ +├── RenderSDK.swift # Integration with RenderSDK +├── ComponentStore.swift # Component binding helpers +├── StoreDebugInspector.swift # Debug tools (DEBUG builds only) +└── StoreUsageExample.swift # Usage examples +``` + +## Key Features Implemented + +✅ **Core Data Models** +- `StoreValue` enum supporting all JSON-compatible types plus `color` and `url` +- `StorePatch` and `StoreChange` for representing mutations +- Full Codable support for serialization + +✅ **Scope and Storage** +- `Scope` enum: `.app` and `.scenario(id: String)` +- `Storage` enum: `.memory`, `.userPrefs()`, `.file(url:)`, `.backend(namespace:)`, `.scenarioSession` +- Semantic versioning with major version handling + +✅ **Validation System** +- Strict and lenient validation modes +- Configurable validation rules per key path +- Type, range, pattern, and length validation + +✅ **Reactive Publishers** +- Combine publishers for reactive data observation +- Component lifecycle binding support +- Thread-safe operations with serial queues + +✅ **Storage Backends** +- **Memory**: Fast, ephemeral storage +- **UserPrefs**: Persistent UserDefaults storage +- **File**: Atomic JSON file storage +- **ScenarioSession**: Scenario lifecycle storage +- **Backend**: Placeholder for remote storage + +✅ **Store Factory** +- Singleton-like store reuse for identical scope+storage combinations +- Version management with automatic reset on major version bumps +- Scenario session lifecycle management + +✅ **SDK Integration** +- Full integration into `RenderSDK` +- Component binding helpers (`ComponentStore`) +- Debug inspector for development builds +- Comprehensive usage examples + +## Usage Examples + +### Basic Usage + +```swift +let sdk = RenderSDK.shared + +// Get stores +let appStore = sdk.getAppStore() // App-level persistent storage +let sessionStore = sdk.getScenarioStore(scenarioID: "checkout") // Session storage + +// Set and get values +appStore.set("user.name", .string("John Doe")) +let userName = appStore.get("user.name") // StoreValue? + +// Typed access +let name: String = try appStore.get("user.name", as: String.self) + +// Reactive updates +let cancellable = appStore.publisher(for: "cart.total") + .sink { value in print("Total changed: \(value)") } +``` + +### Component Binding + +```swift +class MyComponent: NSObject { + @Published var itemCount: Int? + + func setupBindings() { + componentStore.bindStoreValue( + "cart.itemCount", + to: \.itemCount, + in: store + ) + } +} +``` + +### Validation + +```swift +let validationOptions = ValidationOptions( + mode: .strict, + schema: [ + "user.age": ValidationRule( + kind: .integer, + required: true, + min: 0, + max: 150 + ) + ] +) +store.configureValidation(validationOptions) +``` + +### Transactions + +```swift +store.transaction { store in + store.set("cart.item1", .string("Apple")) + store.set("cart.item2", .string("Banana")) + // All changes committed atomically +} +``` + +## Debug Tools (DEBUG builds only) + +```swift +#if DEBUG +let inspector = sdk.getDebugInspector() +let debugInfo = inspector.getStoreDebugInfo(store) +let jsonData = inspector.exportAllData() +#endif +``` + +## Thread Safety + +- All store operations are thread-safe +- Each store has its own serial dispatch queue +- Publishers deliver updates on the main thread +- Transactions ensure atomicity + +## Performance Considerations + +- Memory storage: Fastest, but data lost on app termination +- UserDefaults storage: Persistent, but limited to small data sizes +- File storage: Best for larger datasets, atomic writes +- Scenario session: Automatically cleaned up when scenario ends + +## Error Handling + +The API provides comprehensive error handling through the `StoreError` enum: +- Key path validation errors +- Decode/encode failures +- Validation errors +- Storage unavailability +- Concurrency issues + +All errors implement `LocalizedError` for user-friendly error messages. \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreEnums.swift b/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreEnums.swift new file mode 100644 index 0000000..62364c7 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreEnums.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Represents the scope of a store instance +public enum Scope: Equatable { + case app + case scenario(id: String) + + public var description: String { + switch self { + case .app: return "app" + case .scenario(let id): return "scenario(\(id))" + } + } +} + +/// Represents the storage mechanism for a store instance +public enum Storage: Equatable { + case memory + case userPrefs(suite: String? = nil) + case file(url: URL) + case backend(namespace: String) + case scenarioSession + + public var description: String { + switch self { + case .memory: return "memory" + case .userPrefs(let suite): return "userPrefs(\(suite ?? "default"))" + case .file(let url): return "file(\(url.lastPathComponent))" + case .backend(let namespace): return "backend(\(namespace))" + case .scenarioSession: return "scenarioSession" + } + } + + /// Get a unique identifier for this storage configuration + public var identifier: String { + switch self { + case .memory: return "memory" + case .userPrefs(let suite): return "userPrefs_\(suite ?? "default")" + case .file(let url): return "file_\(url.absoluteString.hashValue)" + case .backend(let namespace): return "backend_\(namespace)" + case .scenarioSession: return "scenarioSession" + } + } +} + +/// Semantic version for store versioning +public struct SemanticVersion: Equatable, Comparable { + public let major: Int + public let minor: Int + public let patch: Int + + public init(major: Int, minor: Int, patch: Int) { + self.major = major + self.minor = minor + self.patch = patch + } + + public init(_ versionString: String) throws { + let components = versionString.split(separator: ".").map { Int($0) } + guard components.count == 3, let major = components[0], let minor = components[1], let patch = components[2] else { + throw StoreError.invalidVersionFormat("Invalid version format: \(versionString)") + } + self.major = major + self.minor = minor + self.patch = patch + } + + public var description: String { + return "\(major).\(minor).\(patch)" + } + + public static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } + + /// Check if this is a major version bump compared to another version + public func isMajorBump(from other: SemanticVersion) -> Bool { + return major > other.major + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreError.swift b/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreError.swift new file mode 100644 index 0000000..14cfe57 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreError.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Errors that can occur during store operations +public enum StoreError: LocalizedError { + case keyPathNotFound(String) + case invalidKeyPath(String) + case decodeFailed(String) + case encodeFailed(String) + case invalidVersionFormat(String) + case validationFailed(String) + case storageUnavailable(String) + case concurrencyError(String) + case transactionFailed(String) + + public var errorDescription: String? { + switch self { + case .keyPathNotFound(let keyPath): + return "Key path not found: \(keyPath)" + case .invalidKeyPath(let keyPath): + return "Invalid key path: \(keyPath)" + case .decodeFailed(let reason): + return "Failed to decode value: \(reason)" + case .encodeFailed(let reason): + return "Failed to encode value: \(reason)" + case .invalidVersionFormat(let version): + return "Invalid version format: \(version)" + case .validationFailed(let reason): + return "Validation failed: \(reason)" + case .storageUnavailable(let reason): + return "Storage unavailable: \(reason)" + case .concurrencyError(let reason): + return "Concurrency error: \(reason)" + case .transactionFailed(let reason): + return "Transaction failed: \(reason)" + } + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Domain/Store/StorePatch.swift b/apps/render-ios-playground/render-ios-playground/Domain/Store/StorePatch.swift new file mode 100644 index 0000000..d8100f1 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Domain/Store/StorePatch.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Represents a single patch operation on a store value +public struct StorePatch: Equatable { + public enum Op: String, Codable, Equatable { + case set + case remove + case merge + } + + public let op: Op + public let keyPath: String + public let oldValue: StoreValue? + public let newValue: StoreValue? + + public init(op: Op, keyPath: String, oldValue: StoreValue? = nil, newValue: StoreValue? = nil) { + self.op = op + self.keyPath = keyPath + self.oldValue = oldValue + self.newValue = newValue + } +} + +/// Represents a collection of patches within a single transaction +public struct StoreChange: Equatable { + public let patches: [StorePatch] + public let transactionID: UUID? + + public init(patches: [StorePatch], transactionID: UUID? = nil) { + self.patches = patches + self.transactionID = transactionID ?? UUID() + } + + /// Check if this change affects a specific key path + public func affects(keyPath: String) -> Bool { + return patches.contains { patch in + patch.keyPath == keyPath || keyPath.hasPrefix(patch.keyPath + ".") + } + } + + /// Get all key paths affected by this change + public var affectedKeyPaths: Set { + var keyPaths = Set() + + for patch in patches { + keyPaths.insert(patch.keyPath) + + // Add parent paths for nested changes + var components = patch.keyPath.split(separator: ".") + while !components.isEmpty { + components.removeLast() + if !components.isEmpty { + keyPaths.insert(components.joined(separator: ".")) + } + } + } + + return keyPaths + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreProtocol.swift b/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreProtocol.swift new file mode 100644 index 0000000..a3d126a --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreProtocol.swift @@ -0,0 +1,81 @@ +import Foundation +import Combine + +/// Protocol for store instances that manage key-value state +public protocol Store: AnyObject { + var scope: Scope { get } + var storage: Storage { get } + + // MARK: - IO Operations + + /// Get a value for the given key path + func get(_ keyPath: String) -> StoreValue? + + /// Get a value for the given key path and decode it to the specified type + func get(_ keyPath: String, as: T.Type) throws -> T + + /// Check if a key path exists in the store + func exists(_ keyPath: String) -> Bool + + // MARK: - Mutation Operations + + /// Set a value for the given key path + func set(_ keyPath: String, _ value: StoreValue) + + /// Merge an object into the given key path + func merge(_ keyPath: String, _ object: [String: StoreValue]) + + /// Remove a value for the given key path + func remove(_ keyPath: String) + + // MARK: - Batch Operations + + /// Perform multiple operations in a single transaction + func transaction(_ block: (Store) -> Void) + + // MARK: - Observation + + /// Get a publisher for a specific key path + func publisher(for keyPath: String) -> AnyPublisher + + /// Get a publisher for multiple key paths + func publisher(for keyPaths: Set) -> AnyPublisher + + // MARK: - Snapshot Operations + + /// Get a snapshot of all values in the store + func snapshot() -> [String: StoreValue] + + /// Replace all values in the store + func replaceAll(with root: [String: StoreValue]) + + // MARK: - Validation + + /// Configure validation options for this store + func configureValidation(_ options: ValidationOptions) + + /// Validate a write operation before it occurs + func validateWrite(_ keyPath: String, _ value: StoreValue) -> ValidationResult +} + +/// Factory protocol for creating and managing Store instances +public protocol StoreFactory: AnyObject { + /// Returns a Store bound to given scope and storage. + /// If a Store already exists for the combination, the same instance may be reused. + func makeStore(scope: Scope, storage: Storage) -> Store + + /// Optionally drop and recreate all stores for a given scope (e.g., on major version bump). + func resetStores(for scope: Scope) + + /// Reset all stores regardless of scope + func resetAllStores() + + /// Get all stores for a given scope + func stores(for scope: Scope) -> [Store] + + /// Get the current version for a scope + func version(for scope: Scope) -> SemanticVersion + + /// Set the version for a scope (will reset stores if major version changes) + func setVersion(_ version: SemanticVersion, for scope: Scope) +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreValidation.swift b/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreValidation.swift new file mode 100644 index 0000000..45bd70f --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreValidation.swift @@ -0,0 +1,191 @@ +import Foundation + +/// Validation options for store operations +public struct ValidationOptions: Equatable { + public enum Mode: String, Codable, Equatable { + case strict + case lenient + } + + public var mode: Mode + public var schema: [String: ValidationRule] + + public init(mode: Mode = .strict, schema: [String: ValidationRule] = [:]) { + self.mode = mode + self.schema = schema + } + + /// Get validation rule for a specific key path + public func rule(for keyPath: String) -> ValidationRule? { + return schema[keyPath] + } + + /// Check if a key path has a validation rule + public func hasRule(for keyPath: String) -> Bool { + return schema[keyPath] != nil + } +} + +/// Represents a validation rule for a store value +public struct ValidationRule: Codable, Equatable { + public enum Kind: String, Codable, Equatable { + case string, number, integer, bool, color, url, array, object + } + + public var kind: Kind + public var required: Bool = false + public var defaultValue: StoreValue? + public var min: Double? + public var max: Double? + public var pattern: String? + public var minLength: Int? + public var maxLength: Int? + + public init( + kind: Kind, + required: Bool = false, + defaultValue: StoreValue? = nil, + min: Double? = nil, + max: Double? = nil, + pattern: String? = nil, + minLength: Int? = nil, + maxLength: Int? = nil + ) { + self.kind = kind + self.required = required + self.defaultValue = defaultValue + self.min = min + self.max = max + self.pattern = pattern + self.minLength = minLength + self.maxLength = maxLength + } + + /// Validate a value against this rule + public func validate(_ value: StoreValue) -> ValidationResult { + // Check type + let typeValid = isValidType(value) + if !typeValid { + return .failed(reason: "Type mismatch: expected \(kind.rawValue), got \(valueType(of: value))") + } + + // Type-specific validation + switch value { + case .string(let stringValue): + return validateString(stringValue) + case .number(let numberValue): + return validateNumber(numberValue) + case .integer(let intValue): + return validateNumber(Double(intValue)) + case .array(let arrayValue): + return validateArray(arrayValue) + case .object(let objectValue): + return validateObject(objectValue) + default: + return .ok + } + } + + private func isValidType(_ value: StoreValue) -> Bool { + switch (kind, value) { + case (.string, .string): return true + case (.number, .number): return true + case (.integer, .integer): return true + case (.bool, .bool): return true + case (.color, .color): return true + case (.url, .url): return true + case (.array, .array): return true + case (.object, .object): return true + default: return false + } + } + + private func valueType(of value: StoreValue) -> String { + switch value { + case .string: return "string" + case .number: return "number" + case .integer: return "integer" + case .bool: return "bool" + case .color: return "color" + case .url: return "url" + case .array: return "array" + case .object: return "object" + case .null: return "null" + } + } + + private func validateString(_ value: String) -> ValidationResult { + if let minLength = minLength, value.count < minLength { + return .failed(reason: "String too short: minimum length is \(minLength), got \(value.count)") + } + + if let maxLength = maxLength, value.count > maxLength { + return .failed(reason: "String too long: maximum length is \(maxLength), got \(value.count)") + } + + if let pattern = pattern, let regex = try? NSRegularExpression(pattern: pattern) { + let range = NSRange(location: 0, length: value.count) + if regex.firstMatch(in: value, range: range) == nil { + return .failed(reason: "String does not match pattern: \(pattern)") + } + } + + return .ok + } + + private func validateNumber(_ value: Double) -> ValidationResult { + if let min = min, value < min { + return .failed(reason: "Number too small: minimum is \(min), got \(value)") + } + + if let max = max, value > max { + return .failed(reason: "Number too large: maximum is \(max), got \(value)") + } + + return .ok + } + + private func validateArray(_ value: [StoreValue]) -> ValidationResult { + if let minLength = minLength, value.count < minLength { + return .failed(reason: "Array too short: minimum length is \(minLength), got \(value.count)") + } + + if let maxLength = maxLength, value.count > maxLength { + return .failed(reason: "Array too long: maximum length is \(maxLength), got \(value.count)") + } + + return .ok + } + + private func validateObject(_ value: [String: StoreValue]) -> ValidationResult { + if let minLength = minLength, value.count < minLength { + return .failed(reason: "Object too small: minimum keys is \(minLength), got \(value.count)") + } + + if let maxLength = maxLength, value.count > maxLength { + return .failed(reason: "Object too large: maximum keys is \(maxLength), got \(value.count)") + } + + return .ok + } +} + +/// Result of a validation operation +public enum ValidationResult { + case ok + case failed(reason: String) + + public var isValid: Bool { + switch self { + case .ok: return true + case .failed: return false + } + } + + public var reason: String? { + switch self { + case .ok: return nil + case .failed(let reason): return reason + } + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreValue.swift b/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreValue.swift new file mode 100644 index 0000000..724abc9 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Domain/Store/StoreValue.swift @@ -0,0 +1,131 @@ +import Foundation + +/// Represents all possible values that can be stored in the Store +public enum StoreValue: Codable, Equatable { + case string(String) + case number(Double) + case integer(Int) + case bool(Bool) + case color(String) + case url(String) + case array([StoreValue]) + case object([String: StoreValue]) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + return + } + + do { + // Try to decode as string first (handles color, url, and string) + let stringValue = try container.decode(String.self) + if stringValue.hasPrefix("#") && (stringValue.count == 7 || stringValue.count == 9) { + self = .color(stringValue) + } else if let url = URL(string: stringValue), url.scheme != nil { + self = .url(stringValue) + } else { + self = .string(stringValue) + } + return + } catch {} + + do { + // Try number + let numberValue = try container.decode(Double.self) + self = .number(numberValue) + return + } catch {} + + do { + // Try integer + let intValue = try container.decode(Int.self) + self = .integer(intValue) + return + } catch {} + + do { + // Try boolean + let boolValue = try container.decode(Bool.self) + self = .bool(boolValue) + return + } catch {} + + do { + // Try array + let arrayValue = try container.decode([StoreValue].self) + self = .array(arrayValue) + return + } catch {} + + do { + // Try object + let objectValue = try container.decode([String: StoreValue].self) + self = .object(objectValue) + return + } catch {} + + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot decode StoreValue from the given data" + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .string(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .integer(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .color(let value): + try container.encode(value) + case .url(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + + /// Get the raw value for type checking and conversion + public var rawValue: Any { + switch self { + case .string(let value): return value + case .number(let value): return value + case .integer(let value): return value + case .bool(let value): return value + case .color(let value): return value + case .url(let value): return value + case .array(let value): return value + case .object(let value): return value + case .null: return NSNull() + } + } + + /// Get the string representation for debugging + public var description: String { + switch self { + case .string(let value): return "string(\"\(value)\")" + case .number(let value): return "number(\(value))" + case .integer(let value): return "integer(\(value))" + case .bool(let value): return "bool(\(value))" + case .color(let value): return "color(\"\(value)\")" + case .url(let value): return "url(\"\(value)\")" + case .array(let value): return "array(\(value.count) items)" + case .object(let value): return "object(\(value.count) keys)" + case .null: return "null" + } + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/BaseStore.swift b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/BaseStore.swift new file mode 100644 index 0000000..e8965fc --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/BaseStore.swift @@ -0,0 +1,276 @@ +import Foundation +import Combine + +/// Base implementation of the Store protocol +public class BaseStore: Store { + public let scope: Scope + public let storage: Storage + + private let storageBackend: StoreStorageBackend + private let serialQueue: DispatchQueue + private var validationOptions = ValidationOptions() + + // MARK: - Publishers + + private let changeSubject = PassthroughSubject() + public lazy var changePublisher = changeSubject.eraseToAnyPublisher() + + private var keyPathSubjects: [String: CurrentValueSubject] = [:] + private var subscriptions: Set = [] + + // MARK: - Initialization + + public init(scope: Scope, storage: Storage, storageBackend: StoreStorageBackend) { + self.scope = scope + self.storage = storage + self.storageBackend = storageBackend + self.serialQueue = DispatchQueue(label: "com.render.store.\(scope.description).\(storage.identifier)", qos: .userInitiated) + + setupPublishers() + } + + // MARK: - IO Operations + + public func get(_ keyPath: String) -> StoreValue? { + return serialQueue.sync { + validateKeyPath(keyPath) + return storageBackend.get(keyPath) + } + } + + public func get(_ keyPath: String, as: T.Type) throws -> T { + return try serialQueue.sync { + validateKeyPath(keyPath) + + guard let value = storageBackend.get(keyPath) else { + throw StoreError.keyPathNotFound(keyPath) + } + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + // Convert StoreValue to JSON data + let jsonData = try encoder.encode(value) + + // Decode to the target type + return try decoder.decode(T.self, from: jsonData) + } + } + + public func exists(_ keyPath: String) -> Bool { + return serialQueue.sync { + validateKeyPath(keyPath) + return storageBackend.exists(keyPath) + } + } + + // MARK: - Mutation Operations + + public func set(_ keyPath: String, _ value: StoreValue) { + serialQueue.async { [weak self] in + self?.performSet(keyPath, value) + } + } + + public func merge(_ keyPath: String, _ object: [String: StoreValue]) { + serialQueue.async { [weak self] in + self?.performMerge(keyPath, object) + } + } + + public func remove(_ keyPath: String) { + serialQueue.async { [weak self] in + self?.performRemove(keyPath) + } + } + + // MARK: - Batch Operations + + public func transaction(_ block: (Store) -> Void) { + serialQueue.async { [weak self] in + self?.performTransaction(block) + } + } + + // MARK: - Snapshot Operations + + public func snapshot() -> [String: StoreValue] { + return serialQueue.sync { + storageBackend.snapshot() + } + } + + public func replaceAll(with root: [String: StoreValue]) { + serialQueue.async { [weak self] in + self?.performReplaceAll(root) + } + } + + // MARK: - Validation + + public func configureValidation(_ options: ValidationOptions) { + serialQueue.async { [weak self] in + self?.validationOptions = options + } + } + + public func validateWrite(_ keyPath: String, _ value: StoreValue) -> ValidationResult { + // Check if there's a validation rule for this key path + if let rule = validationOptions.rule(for: keyPath) { + return rule.validate(value) + } + + return .ok + } + + // MARK: - Private Methods + + private func validateKeyPath(_ keyPath: String) { + guard !keyPath.isEmpty else { + fatalError("Key path cannot be empty") + } + + // Basic key path validation + let components = keyPath.split(separator: ".") + for component in components { + guard !component.isEmpty else { + fatalError("Invalid key path: \(keyPath)") + } + } + } + + private func performSet(_ keyPath: String, _ value: StoreValue) { + validateKeyPath(keyPath) + + // Validate the write + let validationResult = validateWrite(keyPath, value) + if case .failed(let reason) = validationResult { + if validationOptions.mode == .strict { + fatalError("Store validation failed: \(reason)") + } else { + print("Store validation warning: \(reason)") + } + } + + let oldValue = storageBackend.get(keyPath) + storageBackend.set(keyPath, value) + + let patch = StorePatch(op: .set, keyPath: keyPath, oldValue: oldValue, newValue: value) + let change = StoreChange(patches: [patch]) + + notifyChange(change) + } + + private func performMerge(_ keyPath: String, _ object: [String: StoreValue]) { + validateKeyPath(keyPath) + + let oldValue = storageBackend.get(keyPath) + storageBackend.merge(keyPath, object) + + let newValue = storageBackend.get(keyPath) ?? .null + let patch = StorePatch(op: .merge, keyPath: keyPath, oldValue: oldValue, newValue: newValue) + let change = StoreChange(patches: [patch]) + + notifyChange(change) + } + + private func performRemove(_ keyPath: String) { + validateKeyPath(keyPath) + + let oldValue = storageBackend.get(keyPath) + storageBackend.remove(keyPath) + + let patch = StorePatch(op: .remove, keyPath: keyPath, oldValue: oldValue, newValue: nil) + let change = StoreChange(patches: [patch]) + + notifyChange(change) + } + + private func performTransaction(_ block: (Store) -> Void) { + let transactionID = UUID() + + // Create a temporary storage backend that buffers changes + let bufferedBackend = BufferedStorageBackend(wrappedBackend: storageBackend) + + // Create a temporary store with the buffered backend + let tempStore = BaseStore(scope: scope, storage: storage, storageBackend: bufferedBackend) + + // Execute the transaction + block(tempStore) + + // Commit all changes + let patches = bufferedBackend.commit() + + if !patches.isEmpty { + let change = StoreChange(patches: patches, transactionID: transactionID) + notifyChange(change) + } + } + + private func performReplaceAll(with root: [String: StoreValue]) { + let oldSnapshot = storageBackend.snapshot() + + storageBackend.replaceAll(root) + + let newSnapshot = storageBackend.snapshot() + + // Create patches for all changes + var patches: [StorePatch] = [] + + // Find removed keys + for (key, oldValue) in oldSnapshot { + if newSnapshot[key] == nil { + patches.append(StorePatch(op: .remove, keyPath: key, oldValue: oldValue, newValue: nil)) + } + } + + // Find changed/added keys + for (key, newValue) in newSnapshot { + let oldValue = oldSnapshot[key] + let op: StorePatch.Op = oldValue == nil ? .set : .set + patches.append(StorePatch(op: op, keyPath: key, oldValue: oldValue, newValue: newValue)) + } + + if !patches.isEmpty { + let change = StoreChange(patches: patches) + notifyChange(change) + } + } + + private func notifyChange(_ change: StoreChange) { + changeSubject.send(change) + + // Notify affected key path publishers + for keyPath in change.affectedKeyPaths { + keyPathSubjects[keyPath]?.send(get(keyPath)) + } + } + + private func setupPublishers() { + // Set up a publisher for all key paths + changePublisher + .sink { [weak self] change in + // This will be handled by notifyChange + } + .store(in: &subscriptions) + } +} + +// MARK: - Publisher Extensions + +extension BaseStore { + public func publisher(for keyPath: String) -> AnyPublisher { + return keyPathSubjects[keyPath, default: createKeyPathSubject(for: keyPath)].eraseToAnyPublisher() + } + + public func publisher(for keyPaths: Set) -> AnyPublisher { + let publishers = keyPaths.map { publisher(for: $0) } + return Publishers.MergeMany(publishers).eraseToAnyPublisher() + } + + private func createKeyPathSubject(for keyPath: String) -> CurrentValueSubject { + let subject = CurrentValueSubject(get(keyPath)) + keyPathSubjects[keyPath] = subject + return subject + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/FileStorageBackend.swift b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/FileStorageBackend.swift new file mode 100644 index 0000000..11a44d8 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/FileStorageBackend.swift @@ -0,0 +1,192 @@ +import Foundation + +/// File storage backend - persistent JSON file storage with atomic writes +public class FileStorageBackend: StoreStorageBackend { + private let fileURL: URL + private let fileManager: FileManager + private let queue: DispatchQueue + + public init(fileURL: URL) { + self.fileURL = fileURL + self.fileManager = .default + self.queue = DispatchQueue(label: "com.render.store.file", qos: .userInitiated) + + // Ensure directory exists + try? fileManager.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + } + + public func get(_ keyPath: String) -> StoreValue? { + return queue.sync { + guard let data = try? Data(contentsOf: fileURL) else { + return nil + } + + do { + let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + guard let dict = jsonObject as? [String: Any], + let keyData = dict[keyPath] else { + return nil + } + + let encoder = JSONEncoder() + let keyValueData = try encoder.encode(keyData) + let decoder = JSONDecoder() + return try decoder.decode(StoreValue.self, from: keyValueData) + } catch { + print("Failed to read from file storage: \(error)") + return nil + } + } + } + + public func set(_ keyPath: String, _ value: StoreValue) { + queue.async { + self.performSet(keyPath, value, nil) + } + } + + public func merge(_ keyPath: String, _ object: [String: StoreValue]) { + queue.async { + self.performMerge(keyPath, object) + } + } + + public func remove(_ keyPath: String) { + queue.async { + self.performRemove(keyPath) + } + } + + public func exists(_ keyPath: String) -> Bool { + return queue.sync { + guard let data = try? Data(contentsOf: fileURL) else { + return false + } + + let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) + guard let dict = jsonObject as? [String: Any] else { + return false + } + + return dict[keyPath] != nil + } + } + + public func snapshot() -> [String: StoreValue] { + return queue.sync { + guard let data = try? Data(contentsOf: fileURL) else { + return [:] + } + + do { + let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + guard let dict = jsonObject as? [String: Any] else { + return [:] + } + + var result: [String: StoreValue] = [:] + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + for (key, value) in dict { + let keyValueData = try encoder.encode(value) + let storeValue = try decoder.decode(StoreValue.self, from: keyValueData) + result[key] = storeValue + } + + return result + } catch { + print("Failed to read snapshot from file storage: \(error)") + return [:] + } + } + } + + public func replaceAll(_ root: [String: StoreValue]) { + queue.async { + self.performReplaceAll(root) + } + } + + // MARK: - Private Methods + + private func performSet(_ keyPath: String, _ value: StoreValue, _ mergeObject: [String: StoreValue]?) { + do { + var dict = loadCurrentDictionary() + + // Convert StoreValue to JSON-compatible format + let encoder = JSONEncoder() + let valueData = try encoder.encode(value) + let jsonValue = try JSONSerialization.jsonObject(with: valueData, options: []) + + if let mergeObject = mergeObject { + // Handle merge operation + if var currentObject = dict[keyPath] as? [String: Any] { + let mergeEncoder = JSONEncoder() + let mergeData = try mergeEncoder.encode(mergeObject) + let mergeJsonValue = try JSONSerialization.jsonObject(with: mergeData, options: []) + + if let mergeDict = mergeJsonValue as? [String: Any] { + for (key, value) in mergeDict { + currentObject[key] = value + } + dict[keyPath] = currentObject + } + } else { + dict[keyPath] = mergeJsonValue + } + } else { + // Handle set operation + dict[keyPath] = jsonValue + } + + try saveDictionary(dict) + } catch { + print("Failed to write to file storage: \(error)") + } + } + + private func performMerge(_ keyPath: String, _ object: [String: StoreValue]) { + performSet(keyPath, .null, object) + } + + private func performRemove(_ keyPath: String) { + do { + var dict = loadCurrentDictionary() + dict.removeValue(forKey: keyPath) + try saveDictionary(dict) + } catch { + print("Failed to remove from file storage: \(error)") + } + } + + private func performReplaceAll(_ root: [String: StoreValue]) { + do { + var dict: [String: Any] = [:] + let encoder = JSONEncoder() + + for (keyPath, value) in root { + let valueData = try encoder.encode(value) + let jsonValue = try JSONSerialization.jsonObject(with: valueData, options: []) + dict[keyPath] = jsonValue + } + + try saveDictionary(dict) + } catch { + print("Failed to replace all in file storage: \(error)") + } + } + + private func loadCurrentDictionary() -> [String: Any] { + guard let data = try? Data(contentsOf: fileURL) else { + return [:] + } + + return (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] ?? [:] + } + + private func saveDictionary(_ dict: [String: Any]) throws { + let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: fileURL, options: [.atomic]) + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/MemoryStorageBackend.swift b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/MemoryStorageBackend.swift new file mode 100644 index 0000000..633de10 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/MemoryStorageBackend.swift @@ -0,0 +1,48 @@ +import Foundation + +/// In-memory storage backend - fast, ephemeral storage +public class MemoryStorageBackend: StoreStorageBackend { + private var storage: [String: StoreValue] = [:] + + public init() {} + + public func get(_ keyPath: String) -> StoreValue? { + return storage[keyPath] + } + + public func set(_ keyPath: String, _ value: StoreValue) { + storage[keyPath] = value + } + + public func merge(_ keyPath: String, _ object: [String: StoreValue]) { + if var currentObject = storage[keyPath]?.objectValue { + for (key, value) in object { + currentObject[key] = value + } + storage[keyPath] = .object(currentObject) + } else { + storage[keyPath] = .object(object) + } + } + + public func remove(_ keyPath: String) { + storage.removeValue(forKey: keyPath) + } + + public func exists(_ keyPath: String) -> Bool { + return storage[keyPath] != nil + } + + public func snapshot() -> [String: StoreValue] { + return storage + } + + public func replaceAll(_ root: [String: StoreValue]) { + storage = root + } + + /// Clear all data in memory + public func clear() { + storage.removeAll() + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/ScenarioSessionStorageBackend.swift b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/ScenarioSessionStorageBackend.swift new file mode 100644 index 0000000..5d4c57c --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/ScenarioSessionStorageBackend.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Scenario session storage backend - ephemeral storage tied to scenario lifecycle +public class ScenarioSessionStorageBackend: StoreStorageBackend { + private var storage: [String: StoreValue] = [:] + private let scenarioID: String + + public init(scenarioID: String) { + self.scenarioID = scenarioID + } + + public func get(_ keyPath: String) -> StoreValue? { + return storage[keyPath] + } + + public func set(_ keyPath: String, _ value: StoreValue) { + storage[keyPath] = value + } + + public func merge(_ keyPath: String, _ object: [String: StoreValue]) { + if var currentObject = storage[keyPath]?.objectValue { + for (key, value) in object { + currentObject[key] = value + } + storage[keyPath] = .object(currentObject) + } else { + storage[keyPath] = .object(object) + } + } + + public func remove(_ keyPath: String) { + storage.removeValue(forKey: keyPath) + } + + public func exists(_ keyPath: String) -> Bool { + return storage[keyPath] != nil + } + + public func snapshot() -> [String: StoreValue] { + return storage + } + + public func replaceAll(_ root: [String: StoreValue]) { + storage = root + } + + /// Clear all data in this session + public func clear() { + storage.removeAll() + } + + /// Get the scenario ID this backend is tied to + public func getScenarioID() -> String { + return scenarioID + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/ScenarioStoreFactory.swift b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/ScenarioStoreFactory.swift new file mode 100644 index 0000000..535c34e --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/ScenarioStoreFactory.swift @@ -0,0 +1,91 @@ +import Foundation + +/// Factory for creating stores with scenario session support +public class ScenarioStoreFactory: StoreFactory { + private let baseFactory: DefaultStoreFactory + private var scenarioSessionBackends: [String: ScenarioSessionStorageBackend] = [:] + private let sessionQueue = DispatchQueue(label: "com.render.store.scenario", qos: .userInitiated) + + public init(baseFactory: DefaultStoreFactory) { + self.baseFactory = baseFactory + } + + public func makeStore(scope: Scope, storage: Storage) -> Store { + switch storage { + case .scenarioSession: + guard case .scenario(let scenarioID) = scope else { + fatalError("ScenarioSession storage can only be used with scenario scope") + } + + return sessionQueue.sync { + // Create or reuse session backend for this scenario + if let backend = scenarioSessionBackends[scenarioID] { + return BaseStore(scope: scope, storage: storage, storageBackend: backend) + } else { + let backend = ScenarioSessionStorageBackend(scenarioID: scenarioID) + scenarioSessionBackends[scenarioID] = backend + return BaseStore(scope: scope, storage: storage, storageBackend: backend) + } + } + + default: + return baseFactory.makeStore(scope: scope, storage: storage) + } + } + + public func resetStores(for scope: Scope) { + switch scope { + case .scenario(let scenarioID): + sessionQueue.async { [weak self] in + self?.scenarioSessionBackends[scenarioID]?.clear() + } + case .app: + baseFactory.resetStores(for: scope) + } + } + + public func resetAllStores() { + sessionQueue.async { [weak self] in + self?.scenarioSessionBackends.removeAll() + } + baseFactory.resetAllStores() + } + + public func stores(for scope: Scope) -> [Store] { + switch scope { + case .scenario(let scenarioID): + return sessionQueue.sync { + if let backend = scenarioSessionBackends[scenarioID] { + return [BaseStore(scope: scope, storage: .scenarioSession, storageBackend: backend)] + } else { + return [] + } + } + default: + return baseFactory.stores(for: scope) + } + } + + public func version(for scope: Scope) -> SemanticVersion { + return baseFactory.version(for: scope) + } + + public func setVersion(_ version: SemanticVersion, for scope: Scope) { + baseFactory.setVersion(version, for: scope) + } + + /// Clear session data for a specific scenario + public func clearScenarioSession(_ scenarioID: String) { + sessionQueue.async { [weak self] in + self?.scenarioSessionBackends[scenarioID]?.clear() + self?.scenarioSessionBackends.removeValue(forKey: scenarioID) + } + } + + /// Get all active scenario sessions + public func activeScenarioSessions() -> [String] { + return sessionQueue.sync { + Array(scenarioSessionBackends.keys) + } + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/StoreFactory.swift b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/StoreFactory.swift new file mode 100644 index 0000000..295d31b --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/StoreFactory.swift @@ -0,0 +1,178 @@ +import Foundation + +/// Default implementation of StoreFactory +public class DefaultStoreFactory: StoreFactory { + private var stores: [StoreKey: BaseStore] = [:] + private var versions: [Scope: SemanticVersion] = [:] + private let queue = DispatchQueue(label: "com.render.store.factory", qos: .userInitiated) + + public init() { + // Set default versions + versions[.app] = SemanticVersion(major: 1, minor: 0, patch: 0) + } + + public func makeStore(scope: Scope, storage: Storage) -> Store { + return queue.sync { + let key = StoreKey(scope: scope, storage: storage) + + // Check if we already have a store for this combination + if let existingStore = stores[key] { + return existingStore + } + + // Create new storage backend + let storageBackend = createStorageBackend(for: storage) + + // Create new store + let store = BaseStore(scope: scope, storage: storage, storageBackend: storageBackend) + stores[key] = store + + return store + } + } + + public func resetStores(for scope: Scope) { + queue.async { [weak self] in + self?.performResetStores(for: scope) + } + } + + public func resetAllStores() { + queue.async { [weak self] in + self?.performResetAllStores() + } + } + + public func stores(for scope: Scope) -> [Store] { + return queue.sync { + stores.values.filter { $0.scope == scope }.map { $0 as Store } + } + } + + public func version(for scope: Scope) -> SemanticVersion { + return queue.sync { + versions[scope] ?? SemanticVersion(major: 1, minor: 0, patch: 0) + } + } + + public func setVersion(_ version: SemanticVersion, for scope: Scope) { + queue.async { [weak self] in + self?.performSetVersion(version, for: scope) + } + } + + // MARK: - Private Methods + + private func createStorageBackend(for storage: Storage) -> StoreStorageBackend { + switch storage { + case .memory: + return MemoryStorageBackend() + case .userPrefs(let suite): + return UserDefaultsStorageBackend(suite: suite) + case .file(let url): + return FileStorageBackend(fileURL: url) + case .backend(let namespace): + return BackendStorageBackend(namespace: namespace) + case .scenarioSession: + // For scenario session, we need to create a session-specific backend + // This will be handled specially in the concrete implementation + fatalError("ScenarioSession storage should be handled by concrete implementation") + } + } + + private func performResetStores(for scope: Scope) { + let keysToRemove = stores.keys.filter { $0.scope == scope } + for key in keysToRemove { + stores.removeValue(forKey: key) + } + + // Reset version + versions[scope] = SemanticVersion(major: 1, minor: 0, patch: 0) + } + + private func performResetAllStores() { + stores.removeAll() + versions.removeAll() + versions[.app] = SemanticVersion(major: 1, minor: 0, patch: 0) + } + + private func performSetVersion(_ version: SemanticVersion, for scope: Scope) { + let oldVersion = versions[scope] ?? SemanticVersion(major: 1, minor: 0, patch: 0) + + versions[scope] = version + + // If major version changed, reset stores + if version.isMajorBump(from: oldVersion) { + performResetStores(for: scope) + } + } + + // MARK: - Store Key + + private struct StoreKey: Hashable { + let scope: Scope + let storage: Storage + + func hash(into hasher: inout Hasher) { + hasher.combine(scope) + hasher.combine(storage.identifier) + } + + static func == (lhs: StoreKey, rhs: StoreKey) -> Bool { + return lhs.scope == rhs.scope && lhs.storage.identifier == rhs.storage.identifier + } + } +} + +// MARK: - Backend Storage Backend (placeholder) + +/// Placeholder backend storage backend - would integrate with actual backend service +public class BackendStorageBackend: StoreStorageBackend { + private let namespace: String + private var cache: [String: StoreValue] = [:] + + public init(namespace: String) { + self.namespace = namespace + } + + public func get(_ keyPath: String) -> StoreValue? { + // In a real implementation, this would make a network call + // For now, just return from cache + return cache[keyPath] + } + + public func set(_ keyPath: String, _ value: StoreValue) { + cache[keyPath] = value + // In a real implementation, this would make a network call + } + + public func merge(_ keyPath: String, _ object: [String: StoreValue]) { + if var currentObject = cache[keyPath]?.objectValue { + for (key, value) in object { + currentObject[key] = value + } + cache[keyPath] = .object(currentObject) + } else { + cache[keyPath] = .object(object) + } + // In a real implementation, this would make a network call + } + + public func remove(_ keyPath: String) { + cache.removeValue(forKey: keyPath) + // In a real implementation, this would make a network call + } + + public func exists(_ keyPath: String) -> Bool { + return cache[keyPath] != nil + } + + public func snapshot() -> [String: StoreValue] { + return cache + } + + public func replaceAll(_ root: [String: StoreValue]) { + cache = root + // In a real implementation, this would make a network call + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/StoreStorageBackend.swift b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/StoreStorageBackend.swift new file mode 100644 index 0000000..31d18e1 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/StoreStorageBackend.swift @@ -0,0 +1,146 @@ +import Foundation + +/// Protocol for storage backends that handle the actual persistence +public protocol StoreStorageBackend: AnyObject { + func get(_ keyPath: String) -> StoreValue? + func set(_ keyPath: String, _ value: StoreValue) + func merge(_ keyPath: String, _ object: [String: StoreValue]) + func remove(_ keyPath: String) + func exists(_ keyPath: String) -> Bool + func snapshot() -> [String: StoreValue] + func replaceAll(_ root: [String: StoreValue]) +} + +/// A buffered storage backend that collects changes for transactions +public class BufferedStorageBackend: StoreStorageBackend { + private let wrappedBackend: StoreStorageBackend + private var bufferedChanges: [String: StoreValue] = [:] + private var removedKeys: Set = [] + private var mergedObjects: [String: [String: StoreValue]] = [:] + + public init(wrappedBackend: StoreStorageBackend) { + self.wrappedBackend = wrappedBackend + } + + public func get(_ keyPath: String) -> StoreValue? { + if let bufferedValue = bufferedChanges[keyPath] { + return bufferedValue + } + + if removedKeys.contains(keyPath) { + return nil + } + + // For merged objects, we need to construct the merged value + if let mergeData = mergedObjects[keyPath] { + if var currentValue = wrappedBackend.get(keyPath)?.objectValue { + for (key, value) in mergeData { + currentValue[key] = value + } + return .object(currentValue) + } else { + return .object(mergeData) + } + } + + return wrappedBackend.get(keyPath) + } + + public func set(_ keyPath: String, _ value: StoreValue) { + bufferedChanges[keyPath] = value + removedKeys.remove(keyPath) + mergedObjects.removeValue(forKey: keyPath) + } + + public func merge(_ keyPath: String, _ object: [String: StoreValue]) { + mergedObjects[keyPath] = object + removedKeys.remove(keyPath) + } + + public func remove(_ keyPath: String) { + bufferedChanges.removeValue(forKey: keyPath) + removedKeys.insert(keyPath) + mergedObjects.removeValue(forKey: keyPath) + } + + public func exists(_ keyPath: String) -> Bool { + return get(keyPath) != nil + } + + public func snapshot() -> [String: StoreValue] { + var result = wrappedBackend.snapshot() + + // Apply buffered changes + for (key, value) in bufferedChanges { + result[key] = value + } + + // Apply merges + for (keyPath, mergeData) in mergedObjects { + if var currentObject = result[keyPath]?.objectValue { + for (key, value) in mergeData { + currentObject[key] = value + } + result[keyPath] = .object(currentObject) + } else if result[keyPath] == nil { + result[keyPath] = .object(mergeData) + } + } + + // Remove deleted keys + for key in removedKeys { + result.removeValue(forKey: key) + } + + return result + } + + public func replaceAll(_ root: [String: StoreValue]) { + bufferedChanges = root + removedKeys.removeAll() + mergedObjects.removeAll() + } + + /// Commit all buffered changes and return the patches + public func commit() -> [StorePatch] { + var patches: [StorePatch] = [] + + // Apply changes to the wrapped backend + for (keyPath, value) in bufferedChanges { + let oldValue = wrappedBackend.get(keyPath) + wrappedBackend.set(keyPath, value) + patches.append(StorePatch(op: .set, keyPath: keyPath, oldValue: oldValue, newValue: value)) + } + + for keyPath in removedKeys { + let oldValue = wrappedBackend.get(keyPath) + wrappedBackend.remove(keyPath) + patches.append(StorePatch(op: .remove, keyPath: keyPath, oldValue: oldValue, newValue: nil)) + } + + for (keyPath, mergeData) in mergedObjects { + let oldValue = wrappedBackend.get(keyPath) + wrappedBackend.merge(keyPath, mergeData) + let newValue = wrappedBackend.get(keyPath) ?? .null + patches.append(StorePatch(op: .merge, keyPath: keyPath, oldValue: oldValue, newValue: newValue)) + } + + // Clear buffers + bufferedChanges.removeAll() + removedKeys.removeAll() + mergedObjects.removeAll() + + return patches + } +} + +// MARK: - StoreValue Extensions + +extension StoreValue { + var objectValue: [String: StoreValue]? { + switch self { + case .object(let value): return value + default: return nil + } + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/UserDefaultsStorageBackend.swift b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/UserDefaultsStorageBackend.swift new file mode 100644 index 0000000..8112364 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/Infrastructure/Store/UserDefaultsStorageBackend.swift @@ -0,0 +1,100 @@ +import Foundation + +/// UserDefaults storage backend - persistent storage using NSUserDefaults +public class UserDefaultsStorageBackend: StoreStorageBackend { + private let userDefaults: UserDefaults + private let prefix: String + + public init(suite: String? = nil) { + if let suite = suite { + userDefaults = UserDefaults(suiteName: suite) ?? .standard + prefix = "store_\(suite)_" + } else { + userDefaults = .standard + prefix = "store_" + } + } + + public func get(_ keyPath: String) -> StoreValue? { + let key = prefixedKey(keyPath) + guard let data = userDefaults.data(forKey: key) else { + return nil + } + + do { + let decoder = JSONDecoder() + return try decoder.decode(StoreValue.self, from: data) + } catch { + print("Failed to decode StoreValue for key \(keyPath): \(error)") + return nil + } + } + + public func set(_ keyPath: String, _ value: StoreValue) { + let key = prefixedKey(keyPath) + + do { + let encoder = JSONEncoder() + let data = try encoder.encode(value) + userDefaults.set(data, forKey: key) + } catch { + print("Failed to encode StoreValue for key \(keyPath): \(error)") + } + } + + public func merge(_ keyPath: String, _ object: [String: StoreValue]) { + let key = prefixedKey(keyPath) + + if var currentObject = get(keyPath)?.objectValue { + for (mergeKey, mergeValue) in object { + currentObject[mergeKey] = mergeValue + } + set(keyPath, .object(currentObject)) + } else { + set(keyPath, .object(object)) + } + } + + public func remove(_ keyPath: String) { + let key = prefixedKey(keyPath) + userDefaults.removeObject(forKey: key) + } + + public func exists(_ keyPath: String) -> Bool { + let key = prefixedKey(keyPath) + return userDefaults.object(forKey: key) != nil + } + + public func snapshot() -> [String: StoreValue] { + let prefix = self.prefix + let keys = userDefaults.dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) } + + var result: [String: StoreValue] = [:] + for key in keys { + let originalKey = String(key.dropFirst(prefix.count)) + if let value = get(originalKey) { + result[originalKey] = value + } + } + + return result + } + + public func replaceAll(_ root: [String: StoreValue]) { + // Clear existing keys with our prefix + let prefix = self.prefix + let keysToRemove = userDefaults.dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) } + for key in keysToRemove { + userDefaults.removeObject(forKey: key) + } + + // Set new values + for (keyPath, value) in root { + set(keyPath, value) + } + } + + private func prefixedKey(_ keyPath: String) -> String { + return "\(prefix)\(keyPath)" + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/SDK/ComponentStore.swift b/apps/render-ios-playground/render-ios-playground/SDK/ComponentStore.swift new file mode 100644 index 0000000..ad5176b --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/SDK/ComponentStore.swift @@ -0,0 +1,109 @@ +import Foundation +import Combine + +/// Protocol for components that can bind to store data +public protocol StoreBindable: AnyObject { + var storeBindings: [String: AnyCancellable] { get set } + + func bindStoreValue( + _ keyPath: String, + to keyPath: ReferenceWritableKeyPath, + in store: Store + ) -> AnyCancellable + + func bindStoreValue( + _ keyPath: String, + to keyPath: ReferenceWritableKeyPath, + in store: Store, + defaultValue: T + ) -> AnyCancellable +} + +/// Helper class for managing component-store bindings +public class ComponentStore: StoreBindable { + public var storeBindings: [String: AnyCancellable] = [:] + + private weak var owner: AnyObject? + + public init(owner: AnyObject) { + self.owner = owner + } + + public func bindStoreValue( + _ storeKeyPath: String, + to componentKeyPath: ReferenceWritableKeyPath, + in store: Store + ) -> AnyCancellable { + let cancellable = store.publisher(for: storeKeyPath) + .tryMap { storeValue -> T? in + guard let storeValue = storeValue else { return nil } + let data = try JSONEncoder().encode(storeValue) + return try JSONDecoder().decode(T.self, from: data) + } + .replaceError(with: nil) + .receive(on: DispatchQueue.main) + .sink { [weak self] value in + self?[keyPath: componentKeyPath] = value + } + + storeBindings[storeKeyPath] = cancellable + return cancellable + } + + public func bindStoreValue( + _ storeKeyPath: String, + to componentKeyPath: ReferenceWritableKeyPath, + in store: Store, + defaultValue: T + ) -> AnyCancellable { + let cancellable = store.publisher(for: storeKeyPath) + .tryMap { storeValue -> T in + guard let storeValue = storeValue else { return defaultValue } + let data = try JSONEncoder().encode(storeValue) + return try JSONDecoder().decode(T.self, from: data) + } + .replaceError(with: defaultValue) + .receive(on: DispatchQueue.main) + .sink { [weak self] value in + self?[keyPath: componentKeyPath] = value + } + + storeBindings[storeKeyPath] = cancellable + return cancellable + } + + /// Unbind a specific store key path + public func unbind(_ storeKeyPath: String) { + storeBindings[storeKeyPath]?.cancel() + storeBindings.removeValue(forKey: storeKeyPath) + } + + /// Unbind all store bindings + public func unbindAll() { + for cancellable in storeBindings.values { + cancellable.cancel() + } + storeBindings.removeAll() + } + + deinit { + unbindAll() + } +} + +/// Extension to make any NSObject conform to StoreBindable +public extension StoreBindable where Self: NSObject { + var componentStore: ComponentStore { + if let store = objc_getAssociatedObject(self, &AssociatedKeys.componentStore) as? ComponentStore { + return store + } + + let store = ComponentStore(owner: self) + objc_setAssociatedObject(self, &AssociatedKeys.componentStore, store, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return store + } +} + +private struct AssociatedKeys { + static var componentStore = "componentStore" +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/SDK/RenderSDK.swift b/apps/render-ios-playground/render-ios-playground/SDK/RenderSDK.swift index 49f4c26..806f9fe 100644 --- a/apps/render-ios-playground/render-ios-playground/SDK/RenderSDK.swift +++ b/apps/render-ios-playground/render-ios-playground/SDK/RenderSDK.swift @@ -1,16 +1,20 @@ import UIKit import PostgREST import Supabase +import Combine // Public interface for the SDK class RenderSDK { static let shared = RenderSDK() - + private let client = DIContainer.shared.supabaseClient private let componentRegistry = DIContainer.shared.componentRegistry private let scenarioFetcher = DIContainer.shared.scenarioService + private let storeFactory: ScenarioStoreFactory - private init() {} + private init() { + storeFactory = ScenarioStoreFactory(baseFactory: DefaultStoreFactory()) + } // Option 1: Render into an existing view func render( @@ -69,4 +73,38 @@ class RenderSDK { ) return vc } + + // MARK: - Store API + + /// Get a store for app-level data with the specified storage + public func getAppStore(storage: Storage = .userPrefs()) -> Store { + return storeFactory.makeStore(scope: .app, storage: storage) + } + + /// Get a store for scenario-specific data with the specified storage + public func getScenarioStore(scenarioID: String, storage: Storage = .scenarioSession) -> Store { + return storeFactory.makeStore(scope: .scenario(id: scenarioID), storage: storage) + } + + /// Get the store factory for advanced usage + public func getStoreFactory() -> StoreFactory { + return storeFactory + } + + /// Clear all stores for a specific scenario + public func clearScenarioData(_ scenarioID: String) { + storeFactory.clearScenarioSession(scenarioID) + } + + /// Get all active scenario sessions + public func getActiveScenarios() -> [String] { + return storeFactory.activeScenarioSessions() + } + + #if DEBUG + /// Get the debug inspector for development builds + public func getDebugInspector() -> StoreDebugInspector { + return StoreDebugInspector(storeFactory: storeFactory) + } + #endif } diff --git a/apps/render-ios-playground/render-ios-playground/SDK/StoreDebugInspector.swift b/apps/render-ios-playground/render-ios-playground/SDK/StoreDebugInspector.swift new file mode 100644 index 0000000..6485160 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/SDK/StoreDebugInspector.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Debug inspector for store data - only available in DEBUG builds +#if DEBUG +public class StoreDebugInspector { + private let storeFactory: StoreFactory + + public init(storeFactory: StoreFactory) { + self.storeFactory = storeFactory + } + + /// Get all stores for all scopes + public func getAllStores() -> [Store] { + var stores: [Store] = [] + + // Add app stores + stores.append(contentsOf: storeFactory.stores(for: .app)) + + // Add scenario stores + for scenarioID in getAllScenarioIDs() { + stores.append(contentsOf: storeFactory.stores(for: .scenario(id: scenarioID))) + } + + return stores + } + + /// Get all scenario IDs that have stores + public func getAllScenarioIDs() -> [String] { + // This would need to be implemented based on how we track scenarios + // For now, return empty array + return [] + } + + /// Get a specific store + public func getStore(scope: Scope, storage: Storage) -> Store? { + // Try to get the store from the factory + // Note: This creates a new store instance, but that's okay for debugging + return storeFactory.makeStore(scope: scope, storage: storage) + } + + /// Get debug info for a store + public func getStoreDebugInfo(_ store: Store) -> StoreDebugInfo { + return StoreDebugInfo( + scope: store.scope, + storage: store.storage, + keyCount: store.snapshot().count, + keys: Array(store.snapshot().keys) + ) + } + + /// Manually set a value for testing + public func setTestValue(_ value: StoreValue, for keyPath: String, in store: Store) { + store.set(keyPath, value) + } + + /// Manually remove a value for testing + public func removeTestValue(_ keyPath: String, in store: Store) { + store.remove(keyPath) + } + + /// Get mutation log (placeholder - would need to be implemented in store) + public func getMutationLog(for store: Store) -> [String] { + // This would need to be implemented in the store to track mutations + return ["Mutation logging not yet implemented"] + } + + /// Export all store data as JSON for debugging + public func exportAllData() -> String { + let stores = getAllStores() + var exportData: [String: Any] = [:] + + for store in stores { + let scope = store.scope.description + let storage = store.storage.description + + let key = "\(scope)_\(storage)" + exportData[key] = store.snapshot() + } + + do { + let jsonData = try JSONSerialization.data(withJSONObject: exportData, options: [.prettyPrinted]) + return String(data: jsonData, encoding: .utf8) ?? "{}" + } catch { + return "{}" + } + } +} + +/// Debug information for a store +public struct StoreDebugInfo { + public let scope: Scope + public let storage: Storage + public let keyCount: Int + public let keys: [String] + + public var description: String { + return "StoreDebugInfo(scope: \(scope), storage: \(storage), keys: \(keyCount))" + } +} +#endif \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/SDK/StoreUsageExample.swift b/apps/render-ios-playground/render-ios-playground/SDK/StoreUsageExample.swift new file mode 100644 index 0000000..1c83494 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/SDK/StoreUsageExample.swift @@ -0,0 +1,187 @@ +import Foundation + +/// Example usage of the Store API +public class StoreUsageExample { + + private let sdk = RenderSDK.shared + + public func exampleUsage() { + // Get stores for different scopes + let appStore = sdk.getAppStore() // App-level persistent storage + let sessionStore = sdk.getScenarioStore(scenarioID: "checkout") // Scenario session storage + + // Set values + appStore.set("user.name", .string("John Doe")) + appStore.set("user.preferences.theme", .string("dark")) + appStore.set("cart.itemCount", .integer(5)) + + sessionStore.set("checkout.step", .string("payment")) + sessionStore.set("checkout.amount", .number(99.99)) + + // Get values + if let userName = appStore.get("user.name") { + print("User name: \(userName)") + } + + if let itemCount = appStore.get("cart.itemCount") { + print("Cart items: \(itemCount)") + } + + // Get typed values + do { + let theme: String = try appStore.get("user.preferences.theme", as: String.self) + print("Theme: \(theme)") + + let count: Int = try appStore.get("cart.itemCount", as: Int.self) + print("Count: \(count)") + } catch { + print("Error getting typed value: \(error)") + } + + // Transactions + appStore.transaction { store in + store.set("cart.itemCount", .integer(6)) + store.set("cart.lastUpdated", .string("2024-01-01")) + } + + // Merge objects + let newPreferences: [String: StoreValue] = [ + "notifications": .bool(true), + "language": .string("en") + ] + appStore.merge("user.preferences", newPreferences) + + // Validation example + let validationOptions = ValidationOptions( + mode: .strict, + schema: [ + "user.age": ValidationRule( + kind: .integer, + required: true, + min: 0, + max: 150 + ), + "user.email": ValidationRule( + kind: .string, + required: true, + pattern: "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" + ) + ] + ) + appStore.configureValidation(validationOptions) + + // This will validate against the schema + appStore.set("user.age", .integer(25)) + appStore.set("user.email", .string("user@example.com")) + + // Remove values + appStore.remove("cart.lastUpdated") + + // Check existence + if appStore.exists("user.name") { + print("User name exists") + } + + // Snapshot + let snapshot = appStore.snapshot() + print("Store snapshot has \(snapshot.count) keys") + + // Publishers for reactive updates + let cancellable = appStore.publisher(for: "cart.itemCount") + .sink { value in + print("Cart item count changed to: \(value ?? .null)") + } + + // Update the value to trigger the publisher + appStore.set("cart.itemCount", .integer(10)) + + // Cleanup + _ = cancellable + } + + public func reactiveExample() { + let store = sdk.getScenarioStore(scenarioID: "form") + + // Set up reactive bindings + let nameCancellable = store.publisher(for: "form.name") + .compactMap { $0?.stringValue } + .sink { name in + print("Name changed to: \(name)") + } + + let emailCancellable = store.publisher(for: "form.email") + .compactMap { $0?.stringValue } + .sink { email in + print("Email changed to: \(email)") + } + + // Simulate user input + store.set("form.name", .string("John")) + store.set("form.email", .string("john@example.com")) + + // Clean up subscriptions + _ = nameCancellable + _ = emailCancellable + } + + #if DEBUG + public func debugExample() { + let inspector = sdk.getDebugInspector() + + // Inspect all stores + let allStores = inspector.getAllStores() + print("Found \(allStores.count) stores") + + for store in allStores { + let debugInfo = inspector.getStoreDebugInfo(store) + print("Store: \(debugInfo.description)") + } + + // Export data + let jsonData = inspector.exportAllData() + print("Exported data: \(jsonData)") + + // Manual testing + let testStore = sdk.getAppStore(storage: .memory) + inspector.setTestValue(.string("test value"), for: "test.key", in: testStore) + + if let testValue = testStore.get("test.key") { + print("Test value: \(testValue)") + } + } + #endif +} + +// MARK: - Extensions for easier StoreValue creation + +extension StoreValue { + public var stringValue: String? { + if case .string(let value) = self { return value } + return nil + } + + public var intValue: Int? { + if case .integer(let value) = self { return value } + return nil + } + + public var numberValue: Double? { + if case .number(let value) = self { return value } + return nil + } + + public var boolValue: Bool? { + if case .bool(let value) = self { return value } + return nil + } + + public var arrayValue: [StoreValue]? { + if case .array(let value) = self { return value } + return nil + } + + public var objectValue: [String: StoreValue]? { + if case .object(let value) = self { return value } + return nil + } +} \ No newline at end of file