From 53586ed615aa89b1ee2484e48cb38ce68fa0a0c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 24 Sep 2025 12:36:57 +0000 Subject: [PATCH] feat: Implement store and DI for state management Adds store functionality for managing application and scenario state. Co-authored-by: max.mrtnv --- .../DependencyInjection/DIContainer.swift | 12 +- .../render-ios-playground/SDK/RenderSDK.swift | 40 +- .../SDK/RenderViewController.swift | 51 ++- .../render-ios-playground/SDK/Store.swift | 380 ++++++++++++++++++ .../SDK/StoreBackends.swift | 324 +++++++++++++++ .../SDK/StoreExample.swift | 244 +++++++++++ .../SDK/StoreFactory.swift | 77 ++++ .../SDK/StoreManager.swift | 96 +++++ .../SDK/StoreModels.swift | 190 +++++++++ .../SDK/StoreProtocol.swift | 101 +++++ 10 files changed, 1510 insertions(+), 5 deletions(-) create mode 100644 apps/render-ios-playground/render-ios-playground/SDK/Store.swift create mode 100644 apps/render-ios-playground/render-ios-playground/SDK/StoreBackends.swift create mode 100644 apps/render-ios-playground/render-ios-playground/SDK/StoreExample.swift create mode 100644 apps/render-ios-playground/render-ios-playground/SDK/StoreFactory.swift create mode 100644 apps/render-ios-playground/render-ios-playground/SDK/StoreManager.swift create mode 100644 apps/render-ios-playground/render-ios-playground/SDK/StoreModels.swift create mode 100644 apps/render-ios-playground/render-ios-playground/SDK/StoreProtocol.swift diff --git a/apps/render-ios-playground/render-ios-playground/Infrastructure/DependencyInjection/DIContainer.swift b/apps/render-ios-playground/render-ios-playground/Infrastructure/DependencyInjection/DIContainer.swift index 50fbc8a..025f581 100644 --- a/apps/render-ios-playground/render-ios-playground/Infrastructure/DependencyInjection/DIContainer.swift +++ b/apps/render-ios-playground/render-ios-playground/Infrastructure/DependencyInjection/DIContainer.swift @@ -43,8 +43,18 @@ class DIContainer { ) }() + // MARK: - Store + + lazy var storeFactory: StoreFactory = { + return DefaultStoreFactory(version: "1.0.0") + }() + + lazy var storeManager: StoreManager = { + return DefaultStoreManager(storeFactory: storeFactory) + }() + // MARK: SDK - + lazy var componentRegistry: ComponentRegistry = { let registry = ComponentRegistry() let renderers: [Renderer] = [ 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..0b117f3 100644 --- a/apps/render-ios-playground/render-ios-playground/SDK/RenderSDK.swift +++ b/apps/render-ios-playground/render-ios-playground/SDK/RenderSDK.swift @@ -5,10 +5,11 @@ import Supabase // 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 storeManager = DIContainer.shared.storeManager private init() {} @@ -69,4 +70,41 @@ class RenderSDK { ) return vc } + + // MARK: - Store API + + /// Get a store for app-scoped data + public func getAppStore(storage: Storage = .userPrefs()) -> Store { + storeManager.getStore(scope: .app, storage: storage) + } + + /// Get a store for scenario-scoped data + public func getScenarioStore(scenarioID: String, storage: Storage = .memory) -> Store { + storeManager.getStore(scope: .scenario(id: scenarioID), storage: storage) + } + + /// Configure stores for a scenario session + public func configureScenarioStores(scenarioID: String) { + storeManager.configureScenarioStores(scenarioID: scenarioID) + } + + /// Clean up scenario stores when scenario ends + public func cleanupScenarioStores(scenarioID: String) { + storeManager.cleanupScenarioStores(scenarioID: scenarioID) + } + + /// Reset all stores for a specific scope + public func resetStores(for scope: Scope) { + storeManager.resetStores(for: scope) + } + + /// Reset all stores + public func resetAllStores() { + storeManager.resetAllStores() + } + + /// Handle version changes + public func handleVersionChange(from oldVersion: SemanticVersion, to newVersion: SemanticVersion) { + storeManager.handleVersionChange(from: oldVersion, to: newVersion) + } } diff --git a/apps/render-ios-playground/render-ios-playground/SDK/RenderViewController.swift b/apps/render-ios-playground/render-ios-playground/SDK/RenderViewController.swift index b2ca7eb..a1309c4 100644 --- a/apps/render-ios-playground/render-ios-playground/SDK/RenderViewController.swift +++ b/apps/render-ios-playground/render-ios-playground/SDK/RenderViewController.swift @@ -9,12 +9,17 @@ public protocol RenderViewControllerDelegate: AnyObject { public class RenderViewController: UIViewController, ScenarioObserver { weak public var delegate: RenderViewControllerDelegate? - + let scenarioID: String private var scenario: Scenario? private let repository = DIContainer.shared.scenarioRepository private let service = DIContainer.shared.scenarioService private let registry = DIContainer.shared.componentRegistry + private let storeManager = DIContainer.shared.storeManager + + // Store instances for this scenario + private var appStore: Store? + private var scenarioStore: Store? // Root flex container private let rootFlexContainer = UIView() @@ -38,7 +43,8 @@ public class RenderViewController: UIViewController, ScenarioObserver { super.viewDidLoad() view.backgroundColor = .white setupFlexContainer() - + setupStores() + if let scenario = scenario { buildViewHierarchy(from: scenario.mainComponent) } else { @@ -56,10 +62,18 @@ public class RenderViewController: UIViewController, ScenarioObserver { public override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - + Task { await repository.unsubscribeFromScenario(self) } + + // Clean up stores when view disappears + cleanupStores() + } + + deinit { + // Final cleanup + cleanupStores() } override public func viewDidLayoutSubviews() { @@ -144,4 +158,35 @@ public class RenderViewController: UIViewController, ScenarioObserver { return view } + + // MARK: - Store Management + + private func setupStores() { + // Initialize app store (shared across all scenarios) + appStore = storeManager.getStore(scope: .app, storage: .userPrefs()) + + // Initialize scenario store (scoped to this scenario) + scenarioStore = storeManager.getStore(scope: .scenario(id: scenarioID), storage: .memory) + + // Configure scenario stores + storeManager.configureScenarioStores(scenarioID: scenarioID) + } + + private func cleanupStores() { + // Clean up scenario-specific stores + storeManager.cleanupScenarioStores(scenarioID: scenarioID) + + // Clear references + scenarioStore = nil + } + + /// Get the app-scoped store for global data + public func getAppStore() -> Store? { + appStore + } + + /// Get the scenario-scoped store for scenario-specific data + public func getScenarioStore() -> Store? { + scenarioStore + } } diff --git a/apps/render-ios-playground/render-ios-playground/SDK/Store.swift b/apps/render-ios-playground/render-ios-playground/SDK/Store.swift new file mode 100644 index 0000000..97057dc --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/SDK/Store.swift @@ -0,0 +1,380 @@ +import Foundation +import Combine + +/// Default implementation of the Store protocol +public class DefaultStore: Store { + public let scope: Scope + public let storage: Storage + + private let backend: StoreBackend + private let queue: DispatchQueue + private let version: SemanticVersion + + private var data: [String: StoreValue] = [:] + private var validationOptions: ValidationOptions = .lenient + private var subscribers: [String: AnyCancellable] = [:] + private let changeSubject = PassthroughSubject() + private let keyPathSubject = PassthroughSubject<(keyPath: String, value: StoreValue?), Never>() + + public init( + scope: Scope, + storage: Storage, + backend: StoreBackend, + version: SemanticVersion + ) { + self.scope = scope + self.storage = storage + self.backend = backend + self.version = version + self.queue = DispatchQueue(label: "com.render.store.\(scope.id).\(storage.id)", qos: .userInitiated) + + // Load initial data from backend + queue.sync { + self.data = backend.load() + } + } + + // MARK: - IO Operations + + public func get(_ keyPath: String) -> StoreValue? { + queue.sync { + resolveKeyPath(keyPath).flatMap { data[$0] } + } + } + + public func get(_ keyPath: String, as: T.Type) throws -> T { + guard let value = get(keyPath) else { + throw StoreError.keyNotFound(keyPath) + } + + // Convert StoreValue to the requested type + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let jsonData = try encoder.encode(value) + return try decoder.decode(T.self, from: jsonData) + } + + public func exists(_ keyPath: String) -> Bool { + get(keyPath) != nil + } + + // MARK: - Mutations + + public func set(_ keyPath: String, _ value: StoreValue) { + queue.async { + self.performSet(keyPath, value) + } + } + + 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 transaction(_ block: (Store) -> Void) { + queue.async { + let transactionID = UUID() + let oldData = self.data + + block(self) + + // Create patches for all changes + var patches: [StorePatch] = [] + for (keyPath, newValue) in self.data { + if let oldValue = oldData[keyPath] { + if oldValue != newValue { + patches.append(StorePatch( + op: .set, + keyPath: keyPath, + oldValue: oldValue, + newValue: newValue + )) + } + } else { + patches.append(StorePatch( + op: .set, + keyPath: keyPath, + newValue: newValue + )) + } + } + + for keyPath in oldData.keys where self.data[keyPath] == nil { + if let oldValue = oldData[keyPath] { + patches.append(StorePatch( + op: .remove, + keyPath: keyPath, + oldValue: oldValue + )) + } + } + + if !patches.isEmpty { + let change = StoreChange(patches: patches, transactionID: transactionID) + self.notifySubscribers(change) + } + } + } + + // MARK: - Observation + + public func publisher(for keyPath: String) -> AnyPublisher { + keyPathSubject + .filter { $0.keyPath == keyPath } + .map { $0.value } + .eraseToAnyPublisher() + } + + public func publisher(for keyPaths: Set) -> AnyPublisher { + changeSubject + .filter { change in + change.patches.contains { patch in + keyPaths.contains(patch.keyPath) + } + } + .eraseToAnyPublisher() + } + + // MARK: - Snapshot + + public func snapshot() -> [String: StoreValue] { + queue.sync { data } + } + + public func replaceAll(with root: [String: StoreValue]) { + queue.async { + let oldData = self.data + self.data = root + + // Create patches for all changes + var patches: [StorePatch] = [] + for (keyPath, newValue) in root { + if let oldValue = oldData[keyPath] { + if oldValue != newValue { + patches.append(StorePatch( + op: .set, + keyPath: keyPath, + oldValue: oldValue, + newValue: newValue + )) + } + } else { + patches.append(StorePatch( + op: .set, + keyPath: keyPath, + newValue: newValue + )) + } + } + + for keyPath in oldData.keys where root[keyPath] == nil { + if let oldValue = oldData[keyPath] { + patches.append(StorePatch( + op: .remove, + keyPath: keyPath, + oldValue: oldValue + )) + } + } + + if !patches.isEmpty { + let change = StoreChange(patches: patches) + self.notifySubscribers(change) + } + } + } + + // MARK: - Validation + + public func configureValidation(_ options: ValidationOptions) { + queue.async { + self.validationOptions = options + } + } + + public func validateWrite(_ keyPath: String, _ value: StoreValue) -> ValidationResult { + queue.sync { + validateValue(keyPath, value) + } + } + + // MARK: - Private Methods + + private func performSet(_ keyPath: String, _ value: StoreValue) { + let resolvedKeyPath = resolveKeyPath(keyPath) ?? keyPath + let validationResult = validateValue(keyPath, value) + + guard validationResult.isValid else { + if validationOptions.mode == .strict { + return // Don't set invalid values in strict mode + } + } + + let oldValue = data[resolvedKeyPath] + data[resolvedKeyPath] = value + + let patch = StorePatch( + op: .set, + keyPath: keyPath, + oldValue: oldValue, + newValue: value + ) + + let change = StoreChange(patches: [patch]) + notifySubscribers(change) + } + + private func performMerge(_ keyPath: String, _ object: [String: StoreValue]) { + let resolvedKeyPath = resolveKeyPath(keyPath) ?? keyPath + + // Validate each value in the object + var validatedObject = [String: StoreValue]() + for (key, value) in object { + let fullKeyPath = keyPath.isEmpty ? key : "\(keyPath).\(key)" + let validationResult = validateValue(fullKeyPath, value) + + if validationResult.isValid { + validatedObject[key] = value + } else if validationOptions.mode == .lenient { + // Use default value if available + if let rule = validationOptions.schema[fullKeyPath], + let defaultValue = rule.defaultValue { + validatedObject[key] = defaultValue + } + } + } + + let oldValue = data[resolvedKeyPath] + data[resolvedKeyPath] = .object(validatedObject) + + let patch = StorePatch( + op: .merge, + keyPath: keyPath, + oldValue: oldValue, + newValue: .object(validatedObject) + ) + + let change = StoreChange(patches: [patch]) + notifySubscribers(change) + } + + private func performRemove(_ keyPath: String) { + let resolvedKeyPath = resolveKeyPath(keyPath) ?? keyPath + + guard let oldValue = data[resolvedKeyPath] else { return } + + data.removeValue(forKey: resolvedKeyPath) + + let patch = StorePatch( + op: .remove, + keyPath: keyPath, + oldValue: oldValue + ) + + let change = StoreChange(patches: [patch]) + notifySubscribers(change) + } + + private func validateValue(_ keyPath: String, _ value: StoreValue) -> ValidationResult { + guard let rule = validationOptions.schema[keyPath] else { + return .ok // No validation rule means any value is acceptable + } + + // Check required fields + if rule.required && value == .null { + return .failed(reason: "Field '\(keyPath)' is required") + } + + // Check type + let actualKind: ValidationRule.Kind + switch value { + case .string: actualKind = .string + case .number: actualKind = .number + case .integer: actualKind = .integer + case .bool: actualKind = .bool + case .color: actualKind = .color + case .url: actualKind = .url + case .array: actualKind = .array + case .object: actualKind = .object + case .null: return .ok // null is acceptable for optional fields + } + + if actualKind != rule.kind { + if validationOptions.mode == .lenient, let defaultValue = rule.defaultValue { + return .ok // Will use default value in lenient mode + } + return .failed(reason: "Field '\(keyPath)' must be of type \(rule.kind.rawValue), got \(actualKind.rawValue)") + } + + // Check numeric constraints + if let min = rule.min { + switch value { + case .number(let num) where num < min: + return .failed(reason: "Field '\(keyPath)' must be >= \(min), got \(num)") + case .integer(let int) where Double(int) < min: + return .failed(reason: "Field '\(keyPath)' must be >= \(min), got \(int)") + default: + break + } + } + + if let max = rule.max { + switch value { + case .number(let num) where num > max: + return .failed(reason: "Field '\(keyPath)' must be <= \(max), got \(num)") + case .integer(let int) where Double(int) > max: + return .failed(reason: "Field '\(keyPath)' must be <= \(max), got \(int)") + default: + break + } + } + + // Check pattern (for strings) + if let pattern = rule.pattern, case .string(let string) = value { + do { + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(location: 0, length: string.utf16.count) + guard regex.firstMatch(in: string, range: range) != nil else { + return .failed(reason: "Field '\(keyPath)' does not match pattern '\(pattern)'") + } + } catch { + return .failed(reason: "Invalid regex pattern '\(pattern)': \(error.localizedDescription)") + } + } + + return .ok + } + + private func resolveKeyPath(_ keyPath: String) -> String? { + // Simple key path resolution - in a real implementation, + // this would handle dot notation and array indices + guard !keyPath.isEmpty else { return nil } + return keyPath + } + + private func notifySubscribers(_ change: StoreChange) { + // Save to backend + backend.save(data) + + // Notify subscribers + changeSubject.send(change) + keyPathSubject.send((keyPath: change.patches.first?.keyPath ?? "", value: data[change.patches.first?.keyPath ?? ""])) + } +} + +// MARK: - Errors + +public enum StoreError: Error { + case keyNotFound(String) + case validationFailed(String) + case encodingFailed(String) + case decodingFailed(String) +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/SDK/StoreBackends.swift b/apps/render-ios-playground/render-ios-playground/SDK/StoreBackends.swift new file mode 100644 index 0000000..44448e6 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/SDK/StoreBackends.swift @@ -0,0 +1,324 @@ +import Foundation + +// MARK: - Memory Backend + +/// In-memory storage backend - ephemeral, cleared when app terminates +public class MemoryStoreBackend: StoreBackend { + public let storage: Storage = .memory + public let supportsConcurrentAccess: Bool = true + + private var data: [String: StoreValue] = [:] + private let queue = DispatchQueue(label: "com.render.store.memory", qos: .userInitiated) + + public init() {} + + public func load() -> [String: StoreValue] { + queue.sync { data } + } + + public func save(_ data: [String: StoreValue]) { + queue.async { + self.data = data + } + } + + public func clear() { + queue.async { + self.data.removeAll() + } + } +} + +// MARK: - UserPrefs Backend + +/// UserDefaults-based storage backend - persisted across app launches +public class UserPrefsStoreBackend: StoreBackend { + public let storage: Storage + public let supportsConcurrentAccess: Bool = false // UserDefaults is not thread-safe + + private let defaults: UserDefaults + private let prefix: String + + public init(suite: String? = nil) { + self.storage = .userPrefs(suite: suite) + self.prefix = "render.store." + + if let suite = suite { + self.defaults = UserDefaults(suiteName: suite) ?? .standard + } else { + self.defaults = .standard + } + } + + public func load() -> [String: StoreValue] { + guard let data = defaults.dictionary(forKey: prefix + "data") as? [String: Any] else { + return [:] + } + + var result: [String: StoreValue] = [:] + for (key, value) in data { + if let storeValue = StoreValue.from(any: value) { + result[key] = storeValue + } + } + return result + } + + public func save(_ data: [String: StoreValue]) { + let encoder = JSONEncoder() + var anyData: [String: Any] = [:] + + for (key, value) in data { + if let encoded = try? encoder.encode(value) { + anyData[key] = try? JSONSerialization.jsonObject(with: encoded, options: []) + } + } + + defaults.set(anyData, forKey: prefix + "data") + defaults.synchronize() + } + + public func clear() { + defaults.removeObject(forKey: prefix + "data") + defaults.synchronize() + } +} + +// MARK: - File Backend + +/// File-based storage backend - persisted JSON file with atomic writes +public class FileStoreBackend: StoreBackend { + public let storage: Storage + public let supportsConcurrentAccess: Bool = true + + private let fileURL: URL + private let fileManager = FileManager.default + private let queue = DispatchQueue(label: "com.render.store.file", qos: .userInitiated) + + public init(fileURL: URL) { + self.storage = .file(url: fileURL) + self.fileURL = fileURL + } + + public func load() -> [String: StoreValue] { + queue.sync { + guard fileManager.fileExists(atPath: fileURL.path), + let data = try? Data(contentsOf: fileURL) else { + return [:] + } + + let decoder = JSONDecoder() + guard let anyData = try? JSONSerialization.jsonObject(with: data, options: []), + let dict = anyData as? [String: Any] else { + return [:] + } + + var result: [String: StoreValue] = [:] + for (key, value) in dict { + if let storeValue = StoreValue.from(any: value) { + result[key] = storeValue + } + } + return result + } + } + + public func save(_ data: [String: StoreValue]) { + queue.async { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + var anyData: [String: Any] = [:] + for (key, value) in data { + if let encoded = try? encoder.encode(value) { + anyData[key] = try? JSONSerialization.jsonObject(with: encoded, options: []) + } + } + + if let jsonData = try? JSONSerialization.data(withJSONObject: anyData, options: [.prettyPrinted, .sortedKeys]) { + // Atomic write using temporary file + let tempURL = self.fileURL.deletingLastPathComponent().appendingPathComponent(".\(self.fileURL.lastPathComponent).tmp") + try? jsonData.write(to: tempURL, options: [.atomic]) + + do { + try self.fileManager.replaceItem(at: self.fileURL, withItemAt: tempURL) + } catch { + try? self.fileManager.removeItem(at: tempURL) + } + } + } + } + + public func clear() { + queue.async { + try? self.fileManager.removeItem(at: self.fileURL) + } + } +} + +// MARK: - Scenario Session Backend + +/// Session-scoped storage backend - cleared when scenario ends +public class ScenarioSessionStoreBackend: StoreBackend { + public let storage: Storage = .scenarioSession + public let supportsConcurrentAccess: Bool = true + + private var data: [String: StoreValue] = [:] + private let queue = DispatchQueue(label: "com.render.store.session", qos: .userInitiated) + + public init() {} + + public func load() -> [String: StoreValue] { + queue.sync { data } + } + + public func save(_ data: [String: StoreValue]) { + queue.async { + self.data = data + } + } + + public func clear() { + queue.async { + self.data.removeAll() + } + } + + /// Clear session data - useful for cleanup when scenario ends + public func clearSession() { + clear() + } +} + +// MARK: - Remote Backend + +/// Remote storage backend with sync capabilities +public class RemoteStoreBackend: StoreBackendAdapter { + public let storage: Storage + public let supportsConcurrentAccess: Bool = true + + private let namespace: String + private var data: [String: StoreValue] = [:] + private let queue = DispatchQueue(label: "com.render.store.remote", qos: .userInitiated) + private let session: URLSession + private let baseURL: URL + + public init(namespace: String, baseURL: URL = URL(string: "https://api.example.com/stores")!) { + self.storage = .backend(namespace: namespace) + self.namespace = namespace + self.baseURL = baseURL + self.session = URLSession.shared + } + + public func load() -> [String: StoreValue] { + queue.sync { data } + } + + public func save(_ data: [String: StoreValue]) { + queue.async { + self.data = data + // Trigger push to remote + Task { + try? await self.push() + } + } + } + + public func clear() { + queue.async { + self.data.removeAll() + } + } + + // MARK: - Remote Sync + + public func sync() async throws { + try await pull() + try await push() + } + + public func push() async throws { + let encoder = JSONEncoder() + var anyData: [String: Any] = [:] + + for (key, value) in data { + if let encoded = try? encoder.encode(value) { + anyData[key] = try? JSONSerialization.jsonObject(with: encoded, options: []) + } + } + + let url = baseURL.appendingPathComponent(namespace) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let jsonData = try JSONSerialization.data(withJSONObject: anyData, options: []) + request.httpBody = jsonData + + let (_, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw StoreError.encodingFailed("Failed to push data to remote store") + } + } + + public func pull() async throws { + let url = baseURL.appendingPathComponent(namespace) + let request = URLRequest(url: url) + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw StoreError.decodingFailed("Failed to pull data from remote store") + } + + guard let anyData = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + return + } + + var result: [String: StoreValue] = [:] + for (key, value) in anyData { + if let storeValue = StoreValue.from(any: value) { + result[key] = storeValue + } + } + + queue.async { + self.data = result + } + } +} + +// MARK: - StoreValue Conversion Utilities + +extension StoreValue { + static func from(any: Any) -> StoreValue? { + switch any { + case let string as String: + return .string(string) + case let number as Double: + return .number(number) + case let int as Int: + return .integer(int) + case let bool as Bool: + return .bool(bool) + case let array as [Any]: + let storeValues = array.compactMap { StoreValue.from(any: $0) } + return .array(storeValues) + case let dict as [String: Any]: + var result: [String: StoreValue] = [:] + for (key, value) in dict { + if let storeValue = StoreValue.from(any: value) { + result[key] = storeValue + } + } + return .object(result) + case is NSNull: + return .null + default: + return nil + } + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/SDK/StoreExample.swift b/apps/render-ios-playground/render-ios-playground/SDK/StoreExample.swift new file mode 100644 index 0000000..bdb9401 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/SDK/StoreExample.swift @@ -0,0 +1,244 @@ +import Foundation +import Combine + +/// Example usage of the Store API +public class StoreExample { + private let storeFactory: StoreFactory + private var cancellables: Set = [] + + public init(storeFactory: StoreFactory) { + self.storeFactory = storeFactory + } + + /// Demonstrates basic store operations + public func demonstrateBasicUsage() { + print("=== Store API Basic Usage Demo ===\n") + + // Create stores for different scopes + let appStore = storeFactory.makeStore(scope: .app, storage: .userPrefs()) + let scenarioStore = storeFactory.makeStore(scope: .scenario(id: "demo-scenario"), storage: .memory) + + // Basic read/write operations + appStore.set("user.name", .string("John Doe")) + appStore.set("user.age", .integer(30)) + appStore.set("user.preferences", .object([ + "theme": .string("dark"), + "notifications": .bool(true) + ])) + + print("App Store Data:") + print("- Name: \(appStore.get("user.name") ?? .null)") + print("- Age: \(appStore.get("user.age") ?? .null)") + print("- Theme: \(appStore.get("user.preferences.theme") ?? .null)") + + // Scenario-specific data + scenarioStore.set("cart.items", .array([ + .object(["name": .string("Widget A"), "price": .number(10.99)]), + .object(["name": .string("Widget B"), "price": .number(15.49)]) + ])) + scenarioStore.set("cart.total", .number(26.48)) + + print("\nScenario Store Data:") + print("- Cart items count: \(scenarioStore.get("cart.items")?.arrayValue?.count ?? 0)") + print("- Cart total: \(scenarioStore.get("cart.total") ?? .null)") + + // Demonstrate transactions + scenarioStore.transaction { store in + store.set("cart.discount", .number(5.0)) + store.set("cart.total", .number(21.48)) // 26.48 - 5.0 + } + + print("- After discount: \(scenarioStore.get("cart.total") ?? .null)") + + // Demonstrate observation + demonstrateObservation(with: appStore) + } + + /// Demonstrates Combine publishers for observation + public func demonstrateObservation(with store: Store) { + print("\n=== Store Observation Demo ===\n") + + // Observe single key path + store.publisher(for: "user.name") + .sink { value in + print("User name changed to: \(value ?? .null)") + } + .store(in: &cancellables) + + // Observe multiple key paths + store.publisher(for: ["user.age", "user.preferences.theme"]) + .sink { change in + print("Change detected: \(change.patches.count) patches") + for patch in change.patches { + print(" - \(patch.op.rawValue) \(patch.keyPath): \(patch.oldValue ?? .null) -> \(patch.newValue ?? .null)") + } + } + .store(in: &cancellables) + + // Make some changes to trigger observations + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + print("\nTriggering changes...") + store.set("user.name", .string("Jane Doe")) + store.set("user.age", .integer(25)) + store.set("user.preferences.theme", .string("light")) + } + } + + /// Demonstrates validation + public func demonstrateValidation() { + print("\n=== Store Validation Demo ===\n") + + let store = storeFactory.makeStore(scope: .scenario(id: "validation-demo"), storage: .memory) + + // Configure validation rules + let validationOptions = ValidationOptions( + mode: .strict, + schema: [ + "user.age": ValidationRule( + kind: .integer, + required: true, + min: 0, + max: 120 + ), + "user.email": ValidationRule( + kind: .string, + required: true, + pattern: "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" + ) + ] + ) + + store.configureValidation(validationOptions) + + // Valid operations + store.set("user.age", .integer(25)) + store.set("user.email", .string("user@example.com")) + + print("Valid data set successfully:") + print("- Age: \(store.get("user.age") ?? .null)") + print("- Email: \(store.get("user.email") ?? .null)") + + // Invalid operations (will be rejected in strict mode) + store.set("user.age", .string("not a number")) // This will be rejected + store.set("user.email", .string("invalid-email")) // This will be rejected + + print("After invalid operations:") + print("- Age: \(store.get("user.age") ?? .null)") // Still 25 + print("- Email: \(store.get("user.email") ?? .null)") // Still user@example.com + } + + /// Demonstrates different storage backends + public func demonstrateStorageBackends() { + print("\n=== Storage Backends Demo ===\n") + + // Memory storage (ephemeral) + let memoryStore = storeFactory.makeStore(scope: .scenario(id: "memory-demo"), storage: .memory) + memoryStore.set("temp.data", .string("This will be lost when the store is deallocated")) + + // UserPrefs storage (persisted) + let prefsStore = storeFactory.makeStore(scope: .app, storage: .userPrefs()) + prefsStore.set("persistent.data", .string("This persists across app launches")) + + // File storage (JSON file) + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("store-demo.json") + let fileStore = storeFactory.makeStore(scope: .scenario(id: "file-demo"), storage: .file(url: fileURL)) + fileStore.set("file.data", .string("This is stored in a JSON file")) + + print("Data stored in different backends:") + print("- Memory store: \(memoryStore.get("temp.data") ?? .null)") + print("- UserPrefs store: \(prefsStore.get("persistent.data") ?? .null)") + print("- File store: \(fileStore.get("file.data") ?? .null)") + } + + /// Demonstrates advanced features + public func demonstrateAdvancedFeatures() { + print("\n=== Advanced Features Demo ===\n") + + let store = storeFactory.makeStore(scope: .scenario(id: "advanced-demo"), storage: .memory) + + // Batch operations with transactions + store.transaction { store in + store.set("batch.operation1", .string("First operation")) + store.set("batch.operation2", .string("Second operation")) + store.set("batch.operation3", .string("Third operation")) + } + + // Nested data structures + store.set("complex.data", .object([ + "nested": .object([ + "deeply": .object([ + "nested": .object([ + "value": .string("Deep nested value") + ]) + ]) + ]), + "array": .array([ + .string("item 1"), + .string("item 2"), + .object(["key": .string("value")]) + ]) + ])) + + print("Complex data structure:") + print("- Deep nested value: \(store.get("complex.data.nested.deeply.nested.value") ?? .null)") + print("- Array item 0: \(store.get("complex.data.array[0]") ?? .null)") + print("- Array item 2 key: \(store.get("complex.data.array[2].key") ?? .null)") + + // Demonstrate type-safe access + if let age: Int = try? store.get("user.age", as: Int.self) { + print("- Type-safe age access: \(age)") + } + + // Check existence + print("- Has user.age: \(store.exists("user.age"))") + print("- Has nonexistent.key: \(store.exists("nonexistent.key"))") + } + + /// Run all demonstrations + public func runAllDemos() { + demonstrateBasicUsage() + demonstrateValidation() + demonstrateStorageBackends() + demonstrateAdvancedFeatures() + + // Wait a moment for async observations + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + print("\n=== Demo Complete ===") + } + } +} + +// MARK: - StoreValue Convenience Extensions + +extension StoreValue { + var stringValue: String? { + if case .string(let value) = self { return value } + return nil + } + + var intValue: Int? { + if case .integer(let value) = self { return value } + return nil + } + + var doubleValue: Double? { + if case .number(let value) = self { return value } + return nil + } + + var boolValue: Bool? { + if case .bool(let value) = self { return value } + return nil + } + + var arrayValue: [StoreValue]? { + if case .array(let value) = self { return value } + return nil + } + + var objectValue: [String: StoreValue]? { + if case .object(let value) = self { return value } + return nil + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/SDK/StoreFactory.swift b/apps/render-ios-playground/render-ios-playground/SDK/StoreFactory.swift new file mode 100644 index 0000000..0f55811 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/SDK/StoreFactory.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Default implementation of StoreFactory +public class DefaultStoreFactory: StoreFactory { + private var storeCache: [String: Store] = [:] + private let cacheQueue = DispatchQueue(label: "com.render.store.factory", qos: .userInitiated) + private let backendFactory: StoreBackendFactory + private let version: SemanticVersion + + public init(version: String = "1.0.0") { + self.version = SemanticVersion(version) ?? SemanticVersion(major: 1, minor: 0, patch: 0) + self.backendFactory = DefaultStoreBackendFactory() + } + + public func makeStore(scope: Scope, storage: Storage) -> Store { + let cacheKey = "\(scope.id)_\(storage.id)" + + return cacheQueue.sync { + if let cachedStore = storeCache[cacheKey] { + return cachedStore + } + + let backend = backendFactory.createBackend(for: storage) + let store = DefaultStore( + scope: scope, + storage: storage, + backend: backend, + version: version + ) + + storeCache[cacheKey] = store + return store + } + } + + public func resetStores(for scope: Scope) { + cacheQueue.async { + let keysToRemove = self.storeCache.keys.filter { $0.hasPrefix(scope.id + "_") } + for key in keysToRemove { + self.storeCache.removeValue(forKey: key) + } + } + } + + public func resetAllStores() { + cacheQueue.async { + self.storeCache.removeAll() + } + } +} + +// MARK: - Backend Factory + +/// Factory for creating storage backends +public protocol StoreBackendFactory: AnyObject { + func createBackend(for storage: Storage) -> StoreBackend +} + +/// Default implementation of StoreBackendFactory +public class DefaultStoreBackendFactory: StoreBackendFactory { + public init() {} + + public func createBackend(for storage: Storage) -> StoreBackend { + switch storage { + case .memory: + return MemoryStoreBackend() + case .userPrefs(let suite): + return UserPrefsStoreBackend(suite: suite) + case .file(let url): + return FileStoreBackend(fileURL: url) + case .backend(let namespace): + return RemoteStoreBackend(namespace: namespace) + case .scenarioSession: + return ScenarioSessionStoreBackend() + } + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/SDK/StoreManager.swift b/apps/render-ios-playground/render-ios-playground/SDK/StoreManager.swift new file mode 100644 index 0000000..94dbe50 --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/SDK/StoreManager.swift @@ -0,0 +1,96 @@ +import Foundation + +/// Protocol for managing store lifecycle +public protocol StoreManager: AnyObject { + /// Get or create a store for the given scope and storage + func getStore(scope: Scope, storage: Storage) -> Store + + /// Reset all stores for a specific scope + func resetStores(for scope: Scope) + + /// Reset all stores + func resetAllStores() + + /// Configure stores for a scenario session + func configureScenarioStores(scenarioID: String) + + /// Clean up scenario stores when scenario ends + func cleanupScenarioStores(scenarioID: String) + + /// Handle version changes (drop stores on major version bump) + func handleVersionChange(from oldVersion: SemanticVersion, to newVersion: SemanticVersion) +} + +/// Default implementation of StoreManager +public class DefaultStoreManager: StoreManager { + private let storeFactory: StoreFactory + private var scenarioStores: [String: [String: Store]] = [:] // scenarioID -> storageID -> Store + private let queue = DispatchQueue(label: "com.render.store.manager", qos: .userInitiated) + + public init(storeFactory: StoreFactory) { + self.storeFactory = storeFactory + } + + public func getStore(scope: Scope, storage: Storage) -> Store { + switch scope { + case .app: + return storeFactory.makeStore(scope: scope, storage: storage) + case .scenario(let scenarioID): + return queue.sync { + let storageID = storage.id + if var stores = scenarioStores[scenarioID], + let store = stores[storageID] { + return store + } + + let store = storeFactory.makeStore(scope: scope, storage: storage) + + if scenarioStores[scenarioID] == nil { + scenarioStores[scenarioID] = [:] + } + scenarioStores[scenarioID]?[storageID] = store + + return store + } + } + } + + public func resetStores(for scope: Scope) { + switch scope { + case .app: + storeFactory.resetStores(for: scope) + case .scenario(let scenarioID): + queue.async { + self.scenarioStores[scenarioID] = nil + } + storeFactory.resetStores(for: scope) + } + } + + public func resetAllStores() { + queue.async { + self.scenarioStores.removeAll() + } + storeFactory.resetAllStores() + } + + public func configureScenarioStores(scenarioID: String) { + // Set up default stores for a scenario + _ = getStore(scope: .scenario(id: scenarioID), storage: .memory) + _ = getStore(scope: .scenario(id: scenarioID), storage: .scenarioSession) + } + + public func cleanupScenarioStores(scenarioID: String) { + queue.async { + self.scenarioStores[scenarioID] = nil + } + storeFactory.resetStores(for: .scenario(id: scenarioID)) + } + + public func handleVersionChange(from oldVersion: SemanticVersion, to newVersion: SemanticVersion) { + // Drop all scenario data on major version change + if oldVersion.major != newVersion.major { + resetAllStores() + } + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/SDK/StoreModels.swift b/apps/render-ios-playground/render-ios-playground/SDK/StoreModels.swift new file mode 100644 index 0000000..12e98cd --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/SDK/StoreModels.swift @@ -0,0 +1,190 @@ +import Foundation +import Combine + +// MARK: - Core Data Models + +/// Represents a value 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) // Hex color string like "#FF0000" + case url(String) // URL string + case array([StoreValue]) + case object([String: StoreValue]) + case null + + 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.map(\.rawValue) + case .object(let value): return value.mapValues(\.rawValue) + case .null: return NSNull() + } + } +} + +/// Represents a single change operation on the store +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 that occurred in 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() + } +} + +// MARK: - Validation + +/// Configuration for validation behavior +public struct ValidationOptions: Equatable { + public enum Mode: String, Codable, Equatable { + case strict + case lenient + } + + public let mode: Mode + public let schema: [String: ValidationRule] + + public init(mode: Mode, schema: [String: ValidationRule]) { + self.mode = mode + self.schema = schema + } + + public static let strict = ValidationOptions(mode: .strict, schema: [:]) + public static let lenient = ValidationOptions(mode: .lenient, schema: [:]) +} + +/// Defines validation rules for a specific key path +public struct ValidationRule: Codable, Equatable { + public enum Kind: String, Codable, Equatable { + case string, number, integer, bool, color, url, array, object + } + + public let kind: Kind + public let required: Bool + public let defaultValue: StoreValue? + public let min: Double? + public let max: Double? + public let pattern: String? + + public init( + kind: Kind, + required: Bool = false, + defaultValue: StoreValue? = nil, + min: Double? = nil, + max: Double? = nil, + pattern: String? = nil + ) { + self.kind = kind + self.required = required + self.defaultValue = defaultValue + self.min = min + self.max = max + self.pattern = pattern + } +} + +/// Result of validating a store operation +public enum ValidationResult: Equatable { + case ok + case failed(reason: String) + + public var isValid: Bool { + if case .ok = self { return true } + return false + } +} + +/// Utility for semantic versioning +public struct SemanticVersion: Codable, 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) { + let components = versionString.split(separator: ".").compactMap { Int($0) } + guard components.count >= 3 else { return nil } + self.major = components[0] + self.minor = components[1] + self.patch = components[2] + } + + public var stringValue: String { + "\(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 + } +} + +// MARK: - Scope and Storage + +/// Defines the scope of a store instance +public enum Scope: Equatable { + case app + case scenario(id: String) + + public var id: String { + switch self { + case .app: return "app" + case .scenario(let id): return id + } + } +} + +/// Defines 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 id: String { + switch self { + case .memory: return "memory" + case .userPrefs(let suite): return "userPrefs_\(suite ?? "default")" + case .file(let url): return "file_\(url.absoluteString)" + case .backend(let namespace): return "backend_\(namespace)" + case .scenarioSession: return "scenarioSession" + } + } +} \ No newline at end of file diff --git a/apps/render-ios-playground/render-ios-playground/SDK/StoreProtocol.swift b/apps/render-ios-playground/render-ios-playground/SDK/StoreProtocol.swift new file mode 100644 index 0000000..b0d56ef --- /dev/null +++ b/apps/render-ios-playground/render-ios-playground/SDK/StoreProtocol.swift @@ -0,0 +1,101 @@ +import Foundation +import Combine + +/// Protocol defining the core Store interface +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 decoded value for the given key path + func get(_ keyPath: String, as: T.Type) throws -> T + + /// Check if a key path exists + func exists(_ keyPath: String) -> Bool + + // MARK: - Mutations + + /// 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 + + /// Execute multiple operations as a single transaction + func transaction(_ block: (Store) -> Void) + + // MARK: - Observation + + /// Publisher for a single key path + func publisher(for keyPath: String) -> AnyPublisher + + /// Publisher for multiple key paths - publishes StoreChange when any change occurs + func publisher(for keyPaths: Set) -> AnyPublisher + + // MARK: - Snapshot + + /// Get a snapshot of all data + func snapshot() -> [String: StoreValue] + + /// Replace all data with the given root object + func replaceAll(with root: [String: StoreValue]) + + // MARK: - Validation + + /// Configure validation options for this store + func configureValidation(_ options: ValidationOptions) + + /// Validate a write operation + func validateWrite(_ keyPath: String, _ value: StoreValue) -> ValidationResult +} + +/// 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) + + /// Drop all stores and clear caches + func resetAllStores() +} + +/// Protocol for storage backend implementations +public protocol StoreBackend: AnyObject { + var storage: Storage { get } + + /// Load all data from storage + func load() -> [String: StoreValue] + + /// Save data to storage + func save(_ data: [String: StoreValue]) + + /// Clear all data from storage + func clear() + + /// Check if the backend supports concurrent access + var supportsConcurrentAccess: Bool { get } +} + +/// Protocol for backend adapters that need remote synchronization +public protocol StoreBackendAdapter: StoreBackend { + /// Synchronize data with remote source + func sync() async throws + + /// Push local changes to remote source + func push() async throws + + /// Pull changes from remote source + func pull() async throws +} \ No newline at end of file